[object Object]

← back to Exo

Better onboarding UX (#1533)

f370452d7e01204d959fa7d04ac2b6bb6d2f89b3 · 2026-02-23 03:27:28 -0800 · Alex Cheema

## Summary
- **Complete onboarding wizard**: 7-step flow guiding new users from
Welcome → Your Devices (topology) → Add More Devices (animation) →
Choose Model → Download → Load → Chat
- **Native macOS integration**: NSPopover welcome callout anchored to
menu bar icon on first launch, polished DMG installer with
drag-to-Applications arrow
- **Dashboard UX polish**: auto-download on model select, toast
notifications, connection banner, skeleton loading, download progress in
header, recommended model tags, sidebar hidden in home state for cleaner
first impression
- **Settings & menu bar overhaul**: native Settings window with Advanced
tab, onboarding reset, chat sidebar toggle

## Test plan
- [ ] Fresh install: verify onboarding wizard appears and flows Welcome
→ Topology → Animation → Model → Download → Load → Chat
- [ ] Verify topology shows real device data in onboarding step 2
- [ ] Verify selecting a model in the main dashboard picker
auto-triggers download
- [ ] Verify chat sidebar is hidden on home view, appears when chat is
active
- [ ] Verify DMG installer has white background with curved arrow
- [ ] Verify NSPopover appears anchored to menu bar icon on first launch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ryuichi Leo Takashige <leo@exolabs.net>

Files touched

Diff

commit f370452d7e01204d959fa7d04ac2b6bb6d2f89b3
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date:   Mon Feb 23 03:27:28 2026 -0800

    Better onboarding UX (#1533)
    
    ## Summary
    - **Complete onboarding wizard**: 7-step flow guiding new users from
    Welcome → Your Devices (topology) → Add More Devices (animation) →
    Choose Model → Download → Load → Chat
    - **Native macOS integration**: NSPopover welcome callout anchored to
    menu bar icon on first launch, polished DMG installer with
    drag-to-Applications arrow
    - **Dashboard UX polish**: auto-download on model select, toast
    notifications, connection banner, skeleton loading, download progress in
    header, recommended model tags, sidebar hidden in home state for cleaner
    first impression
    - **Settings & menu bar overhaul**: native Settings window with Advanced
    tab, onboarding reset, chat sidebar toggle
    
    ## Test plan
    - [ ] Fresh install: verify onboarding wizard appears and flows Welcome
    → Topology → Animation → Model → Download → Load → Chat
    - [ ] Verify topology shows real device data in onboarding step 2
    - [ ] Verify selecting a model in the main dashboard picker
    auto-triggers download
    - [ ] Verify chat sidebar is hidden on home view, appears when chat is
    active
    - [ ] Verify DMG installer has white background with curved arrow
    - [ ] Verify NSPopover appears anchored to menu bar icon on first launch
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
    Co-authored-by: Ryuichi Leo Takashige <leo@exolabs.net>
---
 app/EXO/EXO/ContentView.swift                      |  172 +-
 app/EXO/EXO/EXOApp.swift                           |   16 +-
 app/EXO/EXO/ExoProcessController.swift             |   18 +
 app/EXO/EXO/Views/FirstLaunchPopout.swift          |  200 ++
 app/EXO/EXO/Views/SettingsView.swift               |  478 ++++
 app/EXO/EXO/Views/SettingsWindowController.swift   |   47 +
 dashboard/src/app.css                              |   65 +
 dashboard/src/lib/components/ChatForm.svelte       |    4 +
 dashboard/src/lib/components/ChatMessages.svelte   |    5 +-
 dashboard/src/lib/components/ChatSidebar.svelte    |   40 +-
 .../src/lib/components/ConnectionBanner.svelte     |   20 +
 dashboard/src/lib/components/DeviceIcon.svelte     |  277 +++
 dashboard/src/lib/components/HeaderNav.svelte      |   81 +-
 dashboard/src/lib/components/ModelCard.svelte      |   26 +
 .../src/lib/components/ModelPickerModal.svelte     |   67 +-
 dashboard/src/lib/components/ToastContainer.svelte |  117 +
 dashboard/src/lib/stores/app.svelte.ts             |   34 +-
 dashboard/src/lib/stores/toast.svelte.ts           |   87 +
 dashboard/src/routes/+layout.svelte                |    4 +
 dashboard/src/routes/+page.svelte                  | 2421 +++++++++++++++++---
 packaging/dmg/background.png                       |  Bin 0 -> 7010 bytes
 packaging/dmg/create-dmg.sh                        |  112 +
 packaging/dmg/generate-background.py               |   91 +
 src/exo/master/api.py                              |   12 +
 src/exo/shared/types/worker/runners.py             |    3 +-
 src/exo/utils/banner.py                            |   30 +
 src/exo/worker/engines/mlx/auto_parallel.py        |  103 +-
 src/exo/worker/engines/mlx/utils_mlx.py            |   38 +-
 src/exo/worker/runner/llm_inference/runner.py      |   22 +-
 .../worker/tests/unittests/test_mlx/conftest.py    |    8 +-
 .../unittests/test_runner/test_event_ordering.py   |    5 +-
 31 files changed, 4060 insertions(+), 543 deletions(-)

diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift
index 4bd620ea..0743df13 100644
--- a/app/EXO/EXO/ContentView.swift
+++ b/app/EXO/EXO/ContentView.swift
@@ -15,10 +15,12 @@ struct ContentView: View {
     @EnvironmentObject private var localNetworkChecker: LocalNetworkChecker
     @EnvironmentObject private var updater: SparkleUpdater
     @EnvironmentObject private var thunderboltBridgeService: ThunderboltBridgeService
+    @EnvironmentObject private var settingsWindowController: SettingsWindowController
     @State private var focusedNode: NodeViewModel?
     @State private var deletingInstanceIDs: Set<String> = []
     @State private var showAllNodes = false
     @State private var showAllInstances = false
+    @State private var baseURLCopied = false
     @State private var showAdvanced = false
     @State private var showDebugInfo = false
     private enum BugReportPhase: Equatable {
@@ -265,139 +267,79 @@ struct ContentView: View {
         VStack(alignment: .leading, spacing: 0) {
             if controller.status != .stopped {
                 dashboardButton
+                baseURLRow
                 Divider()
                     .padding(.vertical, 8)
             } else {
                 Divider()
                     .padding(.vertical, 4)
             }
-            advancedSection
-                .padding(.bottom, 8)
-            controlButton(title: "Quit", tint: .secondary) {
-                controller.stop()
-                NSApplication.shared.terminate(nil)
+            HoverButton(
+                title: "Settings",
+                tint: .primary,
+                trailingSystemImage: "gear"
+            ) {
+                settingsWindowController.open(
+                    controller: controller,
+                    updater: updater,
+                    networkStatusService: networkStatusService,
+                    thunderboltBridgeService: thunderboltBridgeService,
+                    stateService: stateService
+                )
             }
-        }
-    }
-
-    private var advancedSection: some View {
-        VStack(alignment: .leading, spacing: 6) {
-            HStack {
-                Text("Advanced")
-                    .font(.caption)
-                    .foregroundColor(.secondary)
-                Spacer()
-                collapseButton(isExpanded: $showAdvanced)
+            HoverButton(
+                title: "Check for Updates",
+                tint: .primary,
+                trailingSystemImage: "arrow.triangle.2.circlepath"
+            ) {
+                updater.checkForUpdates()
             }
-            .animation(nil, value: showAdvanced)
-            if showAdvanced {
-                VStack(alignment: .leading, spacing: 8) {
-                    VStack(alignment: .leading, spacing: 4) {
-                        Text("Cluster Namespace")
-                            .font(.caption2)
-                            .foregroundColor(.secondary)
-                        HStack {
-                            TextField("optional", text: $pendingNamespace)
-                                .textFieldStyle(.roundedBorder)
-                                .font(.caption2)
-                                .onAppear {
-                                    pendingNamespace = controller.customNamespace
-                                }
-                            Button("Save & Restart") {
-                                controller.customNamespace = pendingNamespace
-                                if controller.status == .running || controller.status == .starting {
-                                    controller.restart()
-                                }
-                            }
-                            .font(.caption2)
-                            .disabled(pendingNamespace == controller.customNamespace)
-                        }
-                    }
-                    VStack(alignment: .leading, spacing: 4) {
-                        Text("HuggingFace Token")
-                            .font(.caption2)
-                            .foregroundColor(.secondary)
-                        HStack {
-                            SecureField("optional", text: $pendingHFToken)
-                                .textFieldStyle(.roundedBorder)
-                                .font(.caption2)
-                                .onAppear {
-                                    pendingHFToken = controller.hfToken
-                                }
-                            Button("Save & Restart") {
-                                controller.hfToken = pendingHFToken
-                                if controller.status == .running || controller.status == .starting {
-                                    controller.restart()
-                                }
-                            }
-                            .font(.caption2)
-                            .disabled(pendingHFToken == controller.hfToken)
-                        }
-                    }
-                    Divider()
-                    HStack {
-                        Toggle(
-                            "Enable Image Models (experimental)", isOn: $pendingEnableImageModels
-                        )
-                        .toggleStyle(.switch)
-                        .font(.caption2)
-                        .onAppear {
-                            pendingEnableImageModels = controller.enableImageModels
-                        }
-
-                        Spacer()
-
-                        Button("Save & Restart") {
-                            controller.enableImageModels = pendingEnableImageModels
-                            if controller.status == .running || controller.status == .starting {
-                                controller.restart()
-                            }
-                        }
-                        .font(.caption2)
-                        .disabled(pendingEnableImageModels == controller.enableImageModels)
-                    }
-                    HoverButton(title: "Check for Updates", small: true) {
-                        updater.checkForUpdates()
-                    }
-                    debugSection
-                    HoverButton(title: "Uninstall", tint: .red, small: true) {
-                        showUninstallConfirmationAlert()
-                    }
-                    .disabled(uninstallInProgress)
-                }
-                .transition(.opacity)
+            .padding(.bottom, 8)
+            HoverButton(title: "Quit", tint: .secondary) {
+                controller.stop()
+                NSApplication.shared.terminate(nil)
             }
         }
-        .animation(.easeInOut(duration: 0.25), value: showAdvanced)
-    }
-
-    private func controlButton(title: String, tint: Color = .primary, action: @escaping () -> Void)
-        -> some View
-    {
-        HoverButton(title: title, tint: tint, trailingSystemImage: nil, action: action)
     }
 
     private var dashboardButton: some View {
-        Button {
+        HoverButton(
+            title: "Web Dashboard",
+            tint: .primary,
+            trailingSystemImage: "arrow.up.right"
+        ) {
             guard let url = URL(string: "http://localhost:52415/") else { return }
             NSWorkspace.shared.open(url)
-        } label: {
-            HStack {
-                Image(systemName: "arrow.up.right.square")
+        }
+    }
+
+    private var baseURLRow: some View {
+        HStack(spacing: 6) {
+            Image(systemName: "link")
+                .imageScale(.small)
+                .foregroundColor(.secondary)
+            Text("localhost:52415/v1")
+                .font(.system(.caption, design: .monospaced))
+                .foregroundColor(.primary)
+            Spacer()
+            Button {
+                NSPasteboard.general.clearContents()
+                NSPasteboard.general.setString("http://localhost:52415/v1", forType: .string)
+                baseURLCopied = true
+                DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+                    baseURLCopied = false
+                }
+            } label: {
+                Image(systemName: baseURLCopied ? "checkmark" : "doc.on.doc")
                     .imageScale(.small)
-                Text("Dashboard")
-                    .fontWeight(.medium)
-                Spacer()
+                    .foregroundColor(baseURLCopied ? .green : .secondary)
+                    .contentTransition(.symbolEffect(.replace))
             }
-            .padding(.vertical, 8)
-            .padding(.horizontal, 10)
-            .background(
-                RoundedRectangle(cornerRadius: 8, style: .continuous)
-                    .fill(Color(red: 1.0, green: 0.87, blue: 0.0).opacity(0.2))
-            )
+            .buttonStyle(.plain)
+            .help("Copy API base URL")
         }
-        .buttonStyle(.plain)
-        .padding(.bottom, 4)
+        .padding(.vertical, 4)
+        .padding(.horizontal, 8)
     }
 
     private func collapseButton(isExpanded: Binding<Bool>) -> some View {
diff --git a/app/EXO/EXO/EXOApp.swift b/app/EXO/EXO/EXOApp.swift
index 3aff58c3..45a6cdda 100644
--- a/app/EXO/EXO/EXOApp.swift
+++ b/app/EXO/EXO/EXOApp.swift
@@ -21,7 +21,9 @@ struct EXOApp: App {
     @StateObject private var localNetworkChecker: LocalNetworkChecker
     @StateObject private var updater: SparkleUpdater
     @StateObject private var thunderboltBridgeService: ThunderboltBridgeService
+    @StateObject private var settingsWindowController: SettingsWindowController
     private let terminationObserver: TerminationObserver
+    private let firstLaunchPopout = FirstLaunchPopout()
     private let ciContext = CIContext(options: nil)
 
     init() {
@@ -43,12 +45,13 @@ struct EXOApp: App {
         _updater = StateObject(wrappedValue: updater)
         let thunderboltBridge = ThunderboltBridgeService(clusterStateService: service)
         _thunderboltBridgeService = StateObject(wrappedValue: thunderboltBridge)
+        _settingsWindowController = StateObject(wrappedValue: SettingsWindowController())
         enableLaunchAtLoginIfNeeded()
         // Install LaunchDaemon to disable Thunderbolt Bridge on startup (prevents network loops)
         NetworkSetupHelper.promptAndInstallIfNeeded()
         // Check local network access periodically (warning disappears when user grants permission)
         localNetwork.startPeriodicChecking(interval: 10)
-        controller.scheduleLaunch(after: 15)
+        controller.scheduleLaunch(after: 5)
         service.startPolling()
         networkStatus.startPolling()
     }
@@ -62,8 +65,19 @@ struct EXOApp: App {
                 .environmentObject(localNetworkChecker)
                 .environmentObject(updater)
                 .environmentObject(thunderboltBridgeService)
+                .environmentObject(settingsWindowController)
         } label: {
             menuBarIcon
+                .onReceive(controller.$isFirstLaunchReady) { ready in
+                    if ready {
+                        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
+                            self.firstLaunchPopout.onComplete = { [weak controller] in
+                                controller?.markOnboardingCompleted()
+                            }
+                            self.firstLaunchPopout.show()
+                        }
+                    }
+                }
         }
         .menuBarExtraStyle(.window)
     }
diff --git a/app/EXO/EXO/ExoProcessController.swift b/app/EXO/EXO/ExoProcessController.swift
index 7566674b..1c9bc12f 100644
--- a/app/EXO/EXO/ExoProcessController.swift
+++ b/app/EXO/EXO/ExoProcessController.swift
@@ -5,6 +5,7 @@ import Foundation
 private let customNamespaceKey = "EXOCustomNamespace"
 private let hfTokenKey = "EXOHFToken"
 private let enableImageModelsKey = "EXOEnableImageModels"
+private let onboardingCompletedKey = "EXOOnboardingCompleted"
 
 @MainActor
 final class ExoProcessController: ObservableObject {
@@ -60,6 +61,9 @@ final class ExoProcessController: ObservableObject {
         }
     }
 
+    /// Fires once when EXO transitions to `.running` for the very first time (fresh install).
+    @Published private(set) var isFirstLaunchReady = false
+
     private var process: Process?
     private var runtimeDirectoryURL: URL?
     private var pendingLaunchTask: Task<Void, Never>?
@@ -113,6 +117,9 @@ final class ExoProcessController: ObservableObject {
             try child.run()
             process = child
             status = .running
+
+            // Show welcome popout on every launch
+            isFirstLaunchReady = true
         } catch {
             process = nil
             status = .failed(message: "Launch error")
@@ -164,6 +171,17 @@ final class ExoProcessController: ObservableObject {
         launch()
     }
 
+    /// Mark onboarding as completed (user interacted with the welcome popout).
+    func markOnboardingCompleted() {
+        UserDefaults.standard.set(true, forKey: onboardingCompletedKey)
+    }
+
+    /// Reset onboarding so the welcome popout appears on next launch.
+    func resetOnboarding() {
+        UserDefaults.standard.removeObject(forKey: onboardingCompletedKey)
+        isFirstLaunchReady = false
+    }
+
     func scheduleLaunch(after seconds: TimeInterval) {
         cancelPendingLaunch()
         let start = max(1, Int(ceil(seconds)))
diff --git a/app/EXO/EXO/Views/FirstLaunchPopout.swift b/app/EXO/EXO/Views/FirstLaunchPopout.swift
new file mode 100644
index 00000000..524c088f
--- /dev/null
+++ b/app/EXO/EXO/Views/FirstLaunchPopout.swift
@@ -0,0 +1,200 @@
+import AppKit
+import SwiftUI
+
+/// A popover callout anchored to the menu bar icon on every launch,
+/// pointing the user to the web dashboard with an arrow connecting to the icon.
+@MainActor
+final class FirstLaunchPopout {
+    private var popover: NSPopover?
+    private var countdownTask: Task<Void, Never>?
+    private static let dashboardURL = "http://localhost:52415/"
+
+    /// Called when the user completes onboarding (clicks Open Dashboard or dismisses).
+    var onComplete: (() -> Void)?
+
+    func show() {
+        guard popover == nil else { return }
+
+        // The status bar button may not exist yet on first launch; retry generously.
+        showWithRetry(attemptsRemaining: 15)
+    }
+
+    private func showWithRetry(attemptsRemaining: Int) {
+        guard attemptsRemaining > 0 else {
+            // Exhausted retries — fall back to just opening the dashboard directly.
+            openDashboard()
+            onComplete?()
+            return
+        }
+
+        guard let button = Self.findStatusItemButton() else {
+            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
+                self?.showWithRetry(attemptsRemaining: attemptsRemaining - 1)
+            }
+            return
+        }
+
+        let pop = NSPopover()
+        pop.behavior = .applicationDefined
+        pop.animates = true
+        pop.contentSize = NSSize(width: 280, height: 120)
+        pop.contentViewController = NSHostingController(
+            rootView: WelcomeCalloutView(
+                countdownDuration: 10,
+                onDismiss: { [weak self] in
+                    self?.onComplete?()
+                    self?.dismiss()
+                },
+                onOpen: { [weak self] in
+                    self?.openDashboard()
+                    self?.onComplete?()
+                    self?.dismiss()
+                }
+            )
+        )
+
+        self.popover = pop
+        pop.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
+
+        // Auto-open dashboard after 10s then dismiss
+        countdownTask = Task {
+            try? await Task.sleep(nanoseconds: 10_000_000_000)
+            if !Task.isCancelled {
+                openDashboard()
+                onComplete?()
+                dismiss()
+            }
+        }
+    }
+
+    func dismiss() {
+        countdownTask?.cancel()
+        countdownTask = nil
+        guard let pop = popover else { return }
+        popover = nil
+        pop.performClose(nil)
+    }
+
+    private func openDashboard() {
+        guard let url = URL(string: Self.dashboardURL) else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    /// Finds the NSStatusBarButton created by SwiftUI's MenuBarExtra.
+    /// Walks the view hierarchy to find the actual button rather than the content view.
+    private static func findStatusItemButton() -> NSView? {
+        for window in NSApp.windows {
+            let className = NSStringFromClass(type(of: window))
+            // Match NSStatusBarWindow or any internal SwiftUI status bar window
+            guard className.contains("StatusBar") || className.contains("MenuBarExtra") else {
+                continue
+            }
+            if let content = window.contentView {
+                if let button = findButton(in: content) {
+                    return button
+                }
+                // Fall back to the content view itself if it has a non-zero frame
+                if content.frame.width > 0 {
+                    return content
+                }
+            }
+        }
+        return nil
+    }
+
+    /// Recursively searches the view hierarchy for an NSStatusBarButton.
+    private static func findButton(in view: NSView) -> NSView? {
+        let className = NSStringFromClass(type(of: view))
+        if className.contains("StatusBarButton") || className.contains("StatusItem") {
+            return view
+        }
+        for subview in view.subviews {
+            if let found = findButton(in: subview) {
+                return found
+            }
+        }
+        return nil
+    }
+}
+
+/// Minimal welcome callout — friendly pointer, not a wall of text.
+/// Rendered inside the NSPopover which provides its own chrome and arrow.
+private struct WelcomeCalloutView: View {
+    let countdownDuration: Int
+    let onDismiss: () -> Void
+    let onOpen: () -> Void
+    @State private var countdown: Int
+    @State private var timerTask: Task<Void, Never>?
+
+    init(countdownDuration: Int, onDismiss: @escaping () -> Void, onOpen: @escaping () -> Void) {
+        self.countdownDuration = countdownDuration
+        self.onDismiss = onDismiss
+        self.onOpen = onOpen
+        self._countdown = State(initialValue: countdownDuration)
+    }
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            HStack(alignment: .top) {
+                Text("EXO is running")
+                    .font(.system(.headline, design: .rounded))
+                    .fontWeight(.semibold)
+                    .foregroundColor(.primary)
+                Spacer()
+                Button {
+                    onDismiss()
+                } label: {
+                    Image(systemName: "xmark.circle.fill")
+                        .font(.system(size: 14))
+                        .foregroundStyle(.tertiary)
+                }
+                .buttonStyle(.plain)
+            }
+
+            Text("Run your first model here:")
+                .font(.system(.subheadline, design: .default))
+                .foregroundColor(.secondary)
+
+            HStack {
+                Button {
+                    onOpen()
+                } label: {
+                    Label("Open Dashboard", systemImage: "arrow.up.right.square")
+                        .font(.system(.caption, design: .default))
+                        .fontWeight(.medium)
+                }
+                .buttonStyle(.borderedProminent)
+                .tint(.accentColor)
+                .controlSize(.small)
+
+                Spacer()
+
+                if countdown > 0 {
+                    Text("Launching in \(countdown) secs...")
+                        .font(.system(.caption2, design: .default))
+                        .foregroundColor(.secondary.opacity(0.6))
+                        .monospacedDigit()
+                }
+            }
+        }
+        .padding(14)
+        .onAppear {
+            startCountdown()
+        }
+        .onDisappear {
+            timerTask?.cancel()
+            timerTask = nil
+        }
+    }
+
+    private func startCountdown() {
+        timerTask = Task {
+            while countdown > 0 {
+                try? await Task.sleep(nanoseconds: 1_000_000_000)
+                if !Task.isCancelled {
+                    countdown -= 1
+                }
+            }
+        }
+    }
+}
diff --git a/app/EXO/EXO/Views/SettingsView.swift b/app/EXO/EXO/Views/SettingsView.swift
new file mode 100644
index 00000000..ed221c89
--- /dev/null
+++ b/app/EXO/EXO/Views/SettingsView.swift
@@ -0,0 +1,478 @@
+import AppKit
+import SwiftUI
+
+/// Native macOS Settings window following Apple HIG.
+/// Organized into General, Model, Advanced, and About sections.
+struct SettingsView: View {
+    @EnvironmentObject private var controller: ExoProcessController
+    @EnvironmentObject private var updater: SparkleUpdater
+    @EnvironmentObject private var networkStatusService: NetworkStatusService
+    @EnvironmentObject private var thunderboltBridgeService: ThunderboltBridgeService
+    @EnvironmentObject private var stateService: ClusterStateService
+
+    @State private var pendingNamespace: String = ""
+    @State private var pendingHFToken: String = ""
+    @State private var pendingEnableImageModels = false
+    @State private var needsRestart = false
+    @State private var bugReportInFlight = false
+    @State private var bugReportMessage: String?
+    @State private var uninstallInProgress = false
+
+    var body: some View {
+        TabView {
+            generalTab
+                .tabItem {
+                    Label("General", systemImage: "gear")
+                }
+            modelTab
+                .tabItem {
+                    Label("Model", systemImage: "cube")
+                }
+            advancedTab
+                .tabItem {
+                    Label("Advanced", systemImage: "wrench.and.screwdriver")
+                }
+            aboutTab
+                .tabItem {
+                    Label("About", systemImage: "info.circle")
+                }
+        }
+        .frame(width: 450, height: 400)
+        .onAppear {
+            pendingNamespace = controller.customNamespace
+            pendingHFToken = controller.hfToken
+            pendingEnableImageModels = controller.enableImageModels
+            needsRestart = false
+        }
+    }
+
+    // MARK: - General Tab
+
+    private var generalTab: some View {
+        Form {
+            Section {
+                LabeledContent("Cluster Namespace") {
+                    TextField("default", text: $pendingNamespace)
+                        .textFieldStyle(.roundedBorder)
+                        .frame(width: 200)
+                }
+                Text("Nodes with the same namespace form a cluster. Leave empty for default.")
+                    .font(.caption)
+                    .foregroundColor(.secondary)
+            }
+
+            Section {
+                LabeledContent("HuggingFace Token") {
+                    SecureField("optional", text: $pendingHFToken)
+                        .textFieldStyle(.roundedBorder)
+                        .frame(width: 200)
+                }
+                Text("Required for gated models. Get yours at huggingface.co/settings/tokens")
+                    .font(.caption)
+                    .foregroundColor(.secondary)
+            }
+
+            Section {
+                HStack {
+                    Spacer()
+                    Button("Save & Restart") {
+                        applyGeneralSettings()
+                    }
+                    .disabled(!hasGeneralChanges)
+                }
+            }
+        }
+        .formStyle(.grouped)
+        .padding()
+    }
+
+    // MARK: - Model Tab
+
+    private var modelTab: some View {
+        Form {
+            Section {
+                Toggle("Enable Image Models (experimental)", isOn: $pendingEnableImageModels)
+                Text("Allow text-to-image and image-to-image models in the model picker.")
+                    .font(.caption)
+                    .foregroundColor(.secondary)
+            }
+
+            Section {
+                HStack {
+                    Spacer()
+                    Button("Save & Restart") {
+                        applyModelSettings()
+                    }
+                    .disabled(!hasModelChanges)
+                }
+            }
+        }
+        .formStyle(.grouped)
+        .padding()
+    }
+
+    // MARK: - Advanced Tab
+
+    private var advancedTab: some View {
+        Form {
+            Section("Onboarding") {
+                HStack {
+                    VStack(alignment: .leading) {
+                        Text("Reset Onboarding")
+                        Text("Opens the dashboard and resets the onboarding wizard.")
+                            .font(.caption)
+                            .foregroundColor(.secondary)
+                    }
+                    Spacer()
+                    Button("Reset") {
+                        guard let url = URL(string: "http://localhost:52415/?reset-onboarding")
+                        else { return }
+                        NSWorkspace.shared.open(url)
+                    }
+                }
+            }
+
+            Section("Debug Info") {
+                LabeledContent("Thunderbolt Bridge") {
+                    Text(thunderboltStatusText)
+                        .foregroundColor(thunderboltStatusColor)
+                }
+
+                VStack(alignment: .leading, spacing: 2) {
+                    clusterThunderboltBridgeView
+                }
+
+                VStack(alignment: .leading, spacing: 2) {
+                    interfaceIpList
+                }
+
+                VStack(alignment: .leading, spacing: 2) {
+                    rdmaStatusView
+                }
+
+                sendBugReportButton
+            }
+
+            Section("Danger Zone") {
+                Button(role: .destructive) {
+                    showUninstallConfirmationAlert()
+                } label: {
+                    HStack {
+                        Text("Uninstall EXO")
+                        Spacer()
+                        Image(systemName: "trash")
+                            .imageScale(.small)
+                    }
+                }
+                .disabled(uninstallInProgress)
+            }
+        }
+        .formStyle(.grouped)
+        .padding()
+    }
+
+    // MARK: - About Tab
+
+    private var aboutTab: some View {
+        Form {
+            Section {
+                LabeledContent("Version") {
+                    Text(buildTag)
+                        .textSelection(.enabled)
+                }
+                LabeledContent("Commit") {
+                    Text(buildCommit)
+                        .font(.system(.body, design: .monospaced))
+                        .textSelection(.enabled)
+                }
+            }
+
+            Section {
+                Button("Check for Updates") {
+                    updater.checkForUpdates()
+                }
+            }
+        }
+        .formStyle(.grouped)
+        .padding()
+    }
+
+    // MARK: - Debug Info Views (moved from ContentView)
+
+    private var thunderboltStatusText: String {
+        switch networkStatusService.status.thunderboltBridgeState {
+        case .some(.disabled):
+            return "Disabled"
+        case .some(.deleted):
+            return "Deleted"
+        case .some(.enabled):
+            return "Enabled"
+        case nil:
+            return "Unknown"
+        }
+    }
+
+    private var thunderboltStatusColor: Color {
+        switch networkStatusService.status.thunderboltBridgeState {
+        case .some(.disabled), .some(.deleted):
+            return .green
+        case .some(.enabled):
+            return .red
+        case nil:
+            return .secondary
+        }
+    }
+
+    private var clusterThunderboltBridgeView: some View {
+        let bridgeStatuses = stateService.latestSnapshot?.nodeThunderboltBridge ?? [:]
+        let localNodeId = stateService.localNodeId
+        let nodeProfiles = stateService.latestSnapshot?.nodeProfiles ?? [:]
+
+        return VStack(alignment: .leading, spacing: 1) {
+            if bridgeStatuses.isEmpty {
+                Text("Cluster TB Bridge: No data")
+                    .font(.caption2)
+                    .foregroundColor(.secondary)
+            } else {
+                Text("Cluster TB Bridge Status:")
+                    .font(.caption2)
+                    .foregroundColor(.secondary)
+                ForEach(Array(bridgeStatuses.keys.sorted()), id: \.self) { nodeId in
+                    if let status = bridgeStatuses[nodeId] {
+                        let nodeName =
+                            nodeProfiles[nodeId]?.friendlyName ?? String(nodeId.prefix(8))
+                        let isLocal = nodeId == localNodeId
+                        let prefix = isLocal ? "  \(nodeName) (local):" : "  \(nodeName):"
+                        let statusText =
+                            !status.exists
+                            ? "N/A"
+                            : (status.enabled ? "Enabled" : "Disabled")
+                        let color: Color =
+                            !status.exists
+                            ? .secondary
+                            : (status.enabled ? .red : .green)
+                        Text("\(prefix) \(statusText)")
+                            .font(.caption2)
+                            .foregroundColor(color)
+                    }
+                }
+            }
+        }
+    }
+
+    private var interfaceIpList: some View {
+        let statuses = networkStatusService.status.interfaceStatuses
+        return VStack(alignment: .leading, spacing: 1) {
+            Text("Interfaces (en0–en7):")
+                .font(.caption2)
+                .foregroundColor(.secondary)
+            if statuses.isEmpty {
+                Text("  Unknown")
+                    .font(.caption2)
+                    .foregroundColor(.secondary)
+            } else {
+                ForEach(statuses, id: \.interfaceName) { status in
+                    let ipText = status.ipAddress ?? "No IP"
+                    Text("  \(status.interfaceName): \(ipText)")
+                        .font(.caption2)
+                        .foregroundColor(status.ipAddress == nil ? .red : .green)
+                }
+            }
+        }
+    }
+
+    private var rdmaStatusView: some View {
+        let rdmaStatuses = stateService.latestSnapshot?.nodeRdmaCtl ?? [:]
+        let localNodeId = stateService.localNodeId
+        let nodeProfiles = stateService.latestSnapshot?.nodeProfiles ?? [:]
+        let localDevices = networkStatusService.status.localRdmaDevices
+        let localPorts = networkStatusService.status.localRdmaActivePorts
+
+        return VStack(alignment: .leading, spacing: 1) {
+            if rdmaStatuses.isEmpty {
+                Text("Cluster RDMA: No data")
+                    .font(.caption2)
+                    .foregroundColor(.secondary)
+            } else {
+                Text("Cluster RDMA Status:")
+                    .font(.caption2)
+                    .foregroundColor(.secondary)
+                ForEach(Array(rdmaStatuses.keys.sorted()), id: \.self) { nodeId in
+                    if let status = rdmaStatuses[nodeId] {
+                        let nodeName =
+                            nodeProfiles[nodeId]?.friendlyName ?? String(nodeId.prefix(8))
+                        let isLocal = nodeId == localNodeId
+                        let prefix = isLocal ? "  \(nodeName) (local):" : "  \(nodeName):"
+                        let statusText = status.enabled ? "Enabled" : "Disabled"
+                        let color: Color = status.enabled ? .green : .orange
+                        Text("\(prefix) \(statusText)")
+                            .font(.caption2)
+                            .foregroundColor(color)
+                    }
+                }
+            }
+            if !localDevices.isEmpty {
+                Text("  Local Devices: \(localDevices.joined(separator: ", "))")
+                    .font(.caption2)
+                    .foregroundColor(.secondary)
+            }
+            if !localPorts.isEmpty {
+                Text("  Local Active Ports:")
+                    .font(.caption2)
+                    .foregroundColor(.secondary)
+                ForEach(localPorts, id: \.device) { port in
+                    Text("    \(port.device) port \(port.port): \(port.state)")
+                        .font(.caption2)
+                        .foregroundColor(.green)
+                }
+            }
+        }
+    }
+
+    private var sendBugReportButton: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Button {
+                Task {
+                    await sendBugReport()
+                }
+            } label: {
+                HStack {
+                    if bugReportInFlight {
+                        ProgressView()
+                            .scaleEffect(0.6)
+                    }
+                    Text("Send Bug Report")
+                        .font(.caption)
+                        .fontWeight(.semibold)
+                    Spacer()
+                }
+            }
+            .disabled(bugReportInFlight)
+
+            if let message = bugReportMessage {
+                Text(message)
+                    .font(.caption2)
+                    .foregroundColor(.secondary)
+                    .fixedSize(horizontal: false, vertical: true)
+            }
+        }
+    }
+
+    // MARK: - Actions
+
+    private func sendBugReport() async {
+        bugReportInFlight = true
+        bugReportMessage = "Collecting logs..."
+        let service = BugReportService()
+        do {
+            let outcome = try await service.sendReport(isManual: true)
+            bugReportMessage = outcome.message
+        } catch {
+            bugReportMessage = error.localizedDescription
+        }
+        bugReportInFlight = false
+    }
+
+    private func showUninstallConfirmationAlert() {
+        let alert = NSAlert()
+        alert.messageText = "Uninstall EXO"
+        alert.informativeText = """
+            This will remove EXO and all its system components:
+
+            • Network configuration daemon
+            • Launch at login registration
+            • EXO network location
+
+            The app will be moved to Trash.
+            """
+        alert.alertStyle = .warning
+        alert.addButton(withTitle: "Uninstall")
+        alert.addButton(withTitle: "Cancel")
+
+        if let uninstallButton = alert.buttons.first {
+            uninstallButton.hasDestructiveAction = true
+        }
+
+        let response = alert.runModal()
+        if response == .alertFirstButtonReturn {
+            performUninstall()
+        }
+    }
+
+    private func performUninstall() {
+        uninstallInProgress = true
+
+        controller.cancelPendingLaunch()
+        controller.stop()
+        stateService.stopPolling()
+
+        DispatchQueue.global(qos: .utility).async {
+            do {
+                try NetworkSetupHelper.uninstall()
+
+                DispatchQueue.main.async {
+                    LaunchAtLoginHelper.disable()
+                    self.moveAppToTrash()
+
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+                        NSApplication.shared.terminate(nil)
+                    }
+                }
+            } catch {
+                DispatchQueue.main.async {
+                    let errorAlert = NSAlert()
+                    errorAlert.messageText = "Uninstall Failed"
+                    errorAlert.informativeText = error.localizedDescription
+                    errorAlert.alertStyle = .critical
+                    errorAlert.addButton(withTitle: "OK")
+                    errorAlert.runModal()
+                    self.uninstallInProgress = false
+                }
+            }
+        }
+    }
+
+    private func moveAppToTrash() {
+        guard let appURL = Bundle.main.bundleURL as URL? else { return }
+        do {
+            try FileManager.default.trashItem(at: appURL, resultingItemURL: nil)
+        } catch {
+            // If we can't trash the app, that's OK - user can do it manually
+        }
+    }
+
+    // MARK: - Helpers
+
+    private var hasGeneralChanges: Bool {
+        pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken
+    }
+
+    private var hasModelChanges: Bool {
+        pendingEnableImageModels != controller.enableImageModels
+    }
+
+    private func applyGeneralSettings() {
+        controller.customNamespace = pendingNamespace
+        controller.hfToken = pendingHFToken
+        restartIfRunning()
+    }
+
+    private func applyModelSettings() {
+        controller.enableImageModels = pendingEnableImageModels
+        restartIfRunning()
+    }
+
+    private func restartIfRunning() {
+        if controller.status == .running || controller.status == .starting {
+            controller.restart()
+        }
+    }
+
+    private var buildTag: String {
+        Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
+    }
+
+    private var buildCommit: String {
+        Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
+    }
+}
diff --git a/app/EXO/EXO/Views/SettingsWindowController.swift b/app/EXO/EXO/Views/SettingsWindowController.swift
new file mode 100644
index 00000000..98517f92
--- /dev/null
+++ b/app/EXO/EXO/Views/SettingsWindowController.swift
@@ -0,0 +1,47 @@
+import AppKit
+import SwiftUI
+
+/// Manages a standalone native macOS Settings window.
+/// Ensures only one instance exists and brings it to front on repeated opens.
+@MainActor
+final class SettingsWindowController: ObservableObject {
+    private var window: NSWindow?
+
+    func open(
+        controller: ExoProcessController,
+        updater: SparkleUpdater,
+        networkStatusService: NetworkStatusService,
+        thunderboltBridgeService: ThunderboltBridgeService,
+        stateService: ClusterStateService
+    ) {
+        if let existing = window, existing.isVisible {
+            existing.makeKeyAndOrderFront(nil)
+            NSApp.activate(ignoringOtherApps: true)
+            return
+        }
+
+        let settingsView = SettingsView()
+            .environmentObject(controller)
+            .environmentObject(updater)
+            .environmentObject(networkStatusService)
+            .environmentObject(thunderboltBridgeService)
+            .environmentObject(stateService)
+
+        let hostingView = NSHostingView(rootView: settingsView)
+
+        let newWindow = NSWindow(
+            contentRect: NSRect(x: 0, y: 0, width: 450, height: 400),
+            styleMask: [.titled, .closable],
+            backing: .buffered,
+            defer: false
+        )
+        newWindow.title = "EXO Settings"
+        newWindow.contentView = hostingView
+        newWindow.center()
+        newWindow.isReleasedWhenClosed = false
+        newWindow.makeKeyAndOrderFront(nil)
+        NSApp.activate(ignoringOtherApps: true)
+
+        window = newWindow
+    }
+}
diff --git a/dashboard/src/app.css b/dashboard/src/app.css
index fc532578..69f1527c 100644
--- a/dashboard/src/app.css
+++ b/dashboard/src/app.css
@@ -202,6 +202,23 @@
 	filter: drop-shadow(0 0 3px oklch(0.85 0.18 85 / 0.5));
 }
 
+/* Onboarding step 2: connection line between devices */
+.onboarding-connection-line {
+	stroke: oklch(0.85 0.18 85 / 0.5);
+	stroke-width: 1.5px;
+	stroke-dasharray: 6, 6;
+	animation: flowAnimation 1s linear infinite;
+	filter: drop-shadow(0 0 4px oklch(0.85 0.18 85 / 0.4));
+}
+
+/* Onboarding step 4: red connection line for disconnect */
+.onboarding-connection-line-red {
+	stroke: rgba(220, 38, 38, 0.7);
+	stroke-width: 1.5px;
+	stroke-dasharray: 6, 6;
+	filter: drop-shadow(0 0 2px rgba(220, 38, 38, 0.3));
+}
+
 .graph-link-active {
 	stroke: oklch(0.85 0.18 85 / 0.8);
 	stroke-width: 2px;
@@ -320,3 +337,51 @@ input:focus, textarea:focus {
 		transform: translate(400px, 400px);
 	}
 }
+
+/* Onboarding smooth fade-in */
+@keyframes onb-fade-in {
+	from { opacity: 0; transform: translateY(8px); }
+	to { opacity: 1; transform: none; }
+}
+
+/* Opacity-only fade-in — no transform, safe for containers with fixed-position children */
+@keyframes onb-fade-opacity {
+	from { opacity: 0; }
+	to { opacity: 1; }
+}
+
+/* Respect reduced motion preference */
+@media (prefers-reduced-motion: reduce) {
+	.shooting-star,
+	.shooting-star::before {
+		animation: none !important;
+		opacity: 0 !important;
+	}
+	.graph-link {
+		animation: none;
+	}
+	.status-pulse {
+		animation: none;
+	}
+	.cursor-blink {
+		animation: none;
+	}
+	.onboarding-connection-line {
+		animation: none;
+	}
+	.onboarding-connection-line-red {
+		animation: none;
+	}
+	[style*="onb-fade-in"],
+	[style*="onb-fade-opacity"] {
+		animation: none !important;
+		opacity: 1 !important;
+	}
+	*,
+	*::before,
+	*::after {
+		transition-duration: 0.01ms !important;
+		animation-duration: 0.01ms !important;
+		animation-iteration-count: 1 !important;
+	}
+}
diff --git a/dashboard/src/lib/components/ChatForm.svelte b/dashboard/src/lib/components/ChatForm.svelte
index aa116c2f..a7bf7673 100644
--- a/dashboard/src/lib/components/ChatForm.svelte
+++ b/dashboard/src/lib/components/ChatForm.svelte
@@ -29,6 +29,7 @@
     showModelSelector?: boolean;
     modelTasks?: Record<string, string[]>;
     modelCapabilities?: Record<string, string[]>;
+    onSend?: () => void;
   }
 
   let {
@@ -39,6 +40,7 @@
     showModelSelector = false,
     modelTasks = {},
     modelCapabilities = {},
+    onSend,
   }: Props = $props();
 
   let message = $state("");
@@ -306,6 +308,8 @@
       );
     }
 
+    onSend?.();
+
     // Refocus the textarea after sending
     setTimeout(() => textareaRef?.focus(), 10);
   }
diff --git a/dashboard/src/lib/components/ChatMessages.svelte b/dashboard/src/lib/components/ChatMessages.svelte
index 1b1d2d07..c716ec3b 100644
--- a/dashboard/src/lib/components/ChatMessages.svelte
+++ b/dashboard/src/lib/components/ChatMessages.svelte
@@ -807,8 +807,8 @@
       >
         AWAITING INPUT
       </p>
-      <p class="text-sm sm:text-xs text-exo-light-gray tracking-wider mt-1">
-        ENTER A QUERY TO BEGIN
+      <p class="text-xs text-white/30 tracking-wider mt-1.5 font-mono">
+        Type a message below &middot; Shift+Enter for newline
       </p>
     </div>
   {/if}
@@ -823,6 +823,7 @@
       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"
+      aria-label="Scroll to bottom of messages"
     >
       <svg
         class="w-5 h-5"
diff --git a/dashboard/src/lib/components/ChatSidebar.svelte b/dashboard/src/lib/components/ChatSidebar.svelte
index 6a822ddf..e7dbe908 100644
--- a/dashboard/src/lib/components/ChatSidebar.svelte
+++ b/dashboard/src/lib/components/ChatSidebar.svelte
@@ -303,7 +303,7 @@
       <div class="py-2">
         <div class="px-4 py-2">
           <span
-            class="text-sm text-white/70 font-mono tracking-wider uppercase"
+            class="text-xs text-exo-light-gray font-mono tracking-wider uppercase"
           >
             {searchQuery ? "SEARCH RESULTS" : "CONVERSATIONS"}
           </span>
@@ -372,39 +372,37 @@
                 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
+                class="group w-full flex items-center justify-between p-2.5 rounded-lg 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'}"
+                  ? 'bg-exo-yellow/5 border border-exo-yellow/30'
+                  : 'hover:bg-white/[0.03] hover:border-white/10 border border-transparent'}"
               >
                 <div class="flex-1 min-w-0 pr-2">
                   <div
-                    class="text-sm truncate {activeId === conversation.id
+                    class="text-sm font-medium truncate {activeId ===
+                    conversation.id
                       ? 'text-exo-yellow'
-                      : 'text-white/90'}"
+                      : 'text-white'}"
                   >
                     {conversation.name}
                   </div>
-                  <div class="text-sm text-white/50 mt-0.5">
+                  <div class="text-xs text-white/60 mt-0.5">
                     {formatDate(conversation.updatedAt)}
                   </div>
-                  <div class="text-sm text-white/70 truncate">
+                  <div class="text-xs text-exo-light-gray 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 class="text-xs text-white/70 font-mono mt-1">
+                      {#if stats.ttftMs}<span class="text-white/50">TTFT</span>
+                        <span class="text-exo-yellow/80"
+                          >{stats.ttftMs.toFixed(0)}ms</span
+                        >{/if}{#if stats.ttftMs && stats.tps}<span
+                          class="text-white/30 mx-1.5">·</span
+                        >{/if}{#if stats.tps}<span class="text-exo-yellow/80"
+                          >{stats.tps.toFixed(1)}</span
+                        >
+                        <span class="text-white/50">tok/s</span>{/if}
                     </div>
                   {/if}
                 </div>
diff --git a/dashboard/src/lib/components/ConnectionBanner.svelte b/dashboard/src/lib/components/ConnectionBanner.svelte
new file mode 100644
index 00000000..3339cf24
--- /dev/null
+++ b/dashboard/src/lib/components/ConnectionBanner.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+  import { isConnected } from "$lib/stores/app.svelte";
+  import { slide } from "svelte/transition";
+
+  const connected = $derived(isConnected());
+</script>
+
+{#if !connected}
+  <div
+    transition:slide={{ duration: 200 }}
+    class="relative z-50 flex items-center justify-center gap-2 px-4 py-2 bg-red-950/80 border-b border-red-500/30"
+    role="alert"
+    aria-live="assertive"
+  >
+    <div class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
+    <span class="text-xs font-mono text-red-300 tracking-wider uppercase">
+      Connection lost &mdash; Reconnecting to backend&hellip;
+    </span>
+  </div>
+{/if}
diff --git a/dashboard/src/lib/components/DeviceIcon.svelte b/dashboard/src/lib/components/DeviceIcon.svelte
new file mode 100644
index 00000000..774be2f9
--- /dev/null
+++ b/dashboard/src/lib/components/DeviceIcon.svelte
@@ -0,0 +1,277 @@
+<script lang="ts">
+  /**
+   * DeviceIcon — renders a device icon as an SVG <g> element.
+   * Uses the exact same proportional math as TopologyGraph.svelte
+   * so that devices look identical in both the topology view and
+   * the onboarding animation.
+   *
+   * Must be placed inside an <svg> element.
+   */
+
+  interface Props {
+    /** "macbook pro" | "mac studio" | "mac mini" etc. */
+    deviceType: string;
+    /** Center X coordinate in SVG space */
+    cx: number;
+    /** Center Y coordinate in SVG space */
+    cy: number;
+    /** Base sizing factor (equivalent to TopologyGraph's nodeRadius) */
+    size?: number;
+    /** RAM usage 0–100 */
+    ramPercent?: number;
+    /** Unique id suffix for clip-path ids */
+    uid?: string;
+  }
+
+  let {
+    deviceType,
+    cx,
+    cy,
+    size = 60,
+    ramPercent = 60,
+    uid = "dev",
+  }: Props = $props();
+
+  // Apple logo path — same constant used by TopologyGraph
+  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;
+
+  const wireColor = "rgba(179,179,179,0.8)";
+  const strokeWidth = 1.5;
+
+  const modelLower = $derived(deviceType.toLowerCase());
+
+  // ── Mac Studio dimensions (same ratios as TopologyGraph) ──
+  const studioW = $derived(size * 1.25);
+  const studioH = $derived(size * 0.85);
+  const studioX = $derived(cx - studioW / 2);
+  const studioY = $derived(cy - studioH / 2);
+  const studioCorner = 4;
+  const studioTopH = $derived(studioH * 0.15);
+
+  // Studio front panel details
+  const studioSlotH = $derived(studioH * 0.14);
+  const studioVSlotW = $derived(studioW * 0.05);
+  const studioVSlotY = $derived(
+    studioY + studioTopH + (studioH - studioTopH) * 0.6,
+  );
+  const studioVSlot1X = $derived(studioX + studioW * 0.18);
+  const studioVSlot2X = $derived(studioX + studioW * 0.28);
+  const studioHSlotW = $derived(studioW * 0.2);
+  const studioHSlotX = $derived(studioX + studioW * 0.5 - studioHSlotW / 2);
+
+  // Studio memory fill
+  const studioMemTotalH = $derived(studioH - studioTopH);
+  const studioMemH = $derived((ramPercent / 100) * studioMemTotalH);
+
+  // ── MacBook dimensions (same ratios as TopologyGraph) ──
+  const mbW = $derived((size * 1.6 * 0.85) / 1.15);
+  const mbH = $derived(size * 0.85);
+  const mbX = $derived(cx - mbW / 2);
+  const mbY = $derived(cy - mbH / 2);
+
+  const mbScreenH = $derived(mbH * 0.7);
+  const mbBaseH = $derived(mbH * 0.3);
+  const mbScreenW = $derived(mbW * 0.85);
+  const mbScreenX = $derived(cx - mbScreenW / 2);
+  const mbBezel = 3;
+
+  // MacBook memory fill
+  const mbMemTotalH = $derived(mbScreenH - mbBezel * 2);
+  const mbMemH = $derived((ramPercent / 100) * mbMemTotalH);
+
+  // Apple logo sizing
+  const mbLogoTargetH = $derived(mbScreenH * 0.22);
+  const mbLogoScale = $derived(mbLogoTargetH / LOGO_NATIVE_HEIGHT);
+  const mbLogoX = $derived(cx - (LOGO_NATIVE_WIDTH * mbLogoScale) / 2);
+  const mbLogoY = $derived(
+    mbY + mbScreenH / 2 - (LOGO_NATIVE_HEIGHT * mbLogoScale) / 2,
+  );
+
+  // MacBook base (trapezoidal)
+  const mbBaseY = $derived(mbY + mbScreenH);
+  const mbBaseTopW = $derived(mbScreenW);
+  const mbBaseBottomW = $derived(mbW);
+  const mbBaseTopX = $derived(cx - mbBaseTopW / 2);
+  const mbBaseBottomX = $derived(cx - mbBaseBottomW / 2);
+
+  // Keyboard
+  const mbKbX = $derived(mbBaseTopX + 6);
+  const mbKbY = $derived(mbBaseY + 3);
+  const mbKbW = $derived(mbBaseTopW - 12);
+  const mbKbH = $derived(mbBaseH * 0.55);
+
+  // Trackpad
+  const mbTpW = $derived(mbBaseTopW * 0.4);
+  const mbTpX = $derived(cx - mbTpW / 2);
+  const mbTpY = $derived(mbBaseY + mbKbH + 5);
+  const mbTpH = $derived(mbBaseH * 0.3);
+
+  // Clip IDs
+  const screenClipId = $derived(`di-screen-${uid}`);
+  const studioClipId = $derived(`di-studio-${uid}`);
+</script>
+
+{#if modelLower === "mac studio" || modelLower === "mac mini"}
+  <!-- Mac Studio / Mac Mini -->
+  <defs>
+    <clipPath id={studioClipId}>
+      <rect
+        x={studioX}
+        y={studioY + studioTopH}
+        width={studioW}
+        height={studioH - studioTopH}
+        rx={studioCorner - 1}
+      />
+    </clipPath>
+  </defs>
+
+  <!-- Main body -->
+  <rect
+    x={studioX}
+    y={studioY}
+    width={studioW}
+    height={studioH}
+    rx={studioCorner}
+    fill="#1a1a1a"
+    stroke={wireColor}
+    stroke-width={strokeWidth}
+  />
+
+  <!-- Memory fill -->
+  {#if ramPercent > 0}
+    <rect
+      x={studioX}
+      y={studioY + studioTopH + (studioMemTotalH - studioMemH)}
+      width={studioW}
+      height={studioMemH}
+      fill="rgba(255,215,0,0.75)"
+      clip-path="url(#{studioClipId})"
+    />
+  {/if}
+
+  <!-- Top surface divider -->
+  <line
+    x1={studioX}
+    y1={studioY + studioTopH}
+    x2={studioX + studioW}
+    y2={studioY + studioTopH}
+    stroke="rgba(179,179,179,0.3)"
+    stroke-width="0.5"
+  />
+
+  <!-- Front panel: vertical slots -->
+  <rect
+    x={studioVSlot1X - studioVSlotW / 2}
+    y={studioVSlotY}
+    width={studioVSlotW}
+    height={studioSlotH}
+    fill="rgba(0,0,0,0.35)"
+    rx="1.5"
+  />
+  <rect
+    x={studioVSlot2X - studioVSlotW / 2}
+    y={studioVSlotY}
+    width={studioVSlotW}
+    height={studioSlotH}
+    fill="rgba(0,0,0,0.35)"
+    rx="1.5"
+  />
+
+  <!-- Horizontal slot (SD card) -->
+  <rect
+    x={studioHSlotX}
+    y={studioVSlotY}
+    width={studioHSlotW}
+    height={studioSlotH * 0.6}
+    fill="rgba(0,0,0,0.35)"
+    rx="1"
+  />
+{:else}
+  <!-- MacBook Pro -->
+  <defs>
+    <clipPath id={screenClipId}>
+      <rect
+        x={mbScreenX + mbBezel}
+        y={mbY + mbBezel}
+        width={mbScreenW - mbBezel * 2}
+        height={mbScreenH - mbBezel * 2}
+        rx="2"
+      />
+    </clipPath>
+  </defs>
+
+  <!-- Screen outer frame -->
+  <rect
+    x={mbScreenX}
+    y={mbY}
+    width={mbScreenW}
+    height={mbScreenH}
+    rx="3"
+    fill="#1a1a1a"
+    stroke={wireColor}
+    stroke-width={strokeWidth}
+  />
+
+  <!-- Screen inner (dark) -->
+  <rect
+    x={mbScreenX + mbBezel}
+    y={mbY + mbBezel}
+    width={mbScreenW - mbBezel * 2}
+    height={mbScreenH - mbBezel * 2}
+    rx="2"
+    fill="#0a0a12"
+  />
+
+  <!-- Memory fill on screen -->
+  {#if ramPercent > 0}
+    <rect
+      x={mbScreenX + mbBezel}
+      y={mbY + mbBezel + (mbMemTotalH - mbMemH)}
+      width={mbScreenW - mbBezel * 2}
+      height={mbMemH}
+      fill="rgba(255,215,0,0.85)"
+      clip-path="url(#{screenClipId})"
+    />
+  {/if}
+
+  <!-- Apple logo -->
+  <path
+    d={APPLE_LOGO_PATH}
+    transform="translate({mbLogoX}, {mbLogoY}) scale({mbLogoScale})"
+    fill="#FFFFFF"
+    opacity="0.9"
+  />
+
+  <!-- Keyboard base (trapezoidal) -->
+  <path
+    d="M {mbBaseTopX} {mbBaseY} L {mbBaseTopX +
+      mbBaseTopW} {mbBaseY} L {mbBaseBottomX + mbBaseBottomW} {mbBaseY +
+      mbBaseH} L {mbBaseBottomX} {mbBaseY + mbBaseH} Z"
+    fill="#2c2c2c"
+    stroke={wireColor}
+    stroke-width="1"
+  />
+
+  <!-- Keyboard area -->
+  <rect
+    x={mbKbX}
+    y={mbKbY}
+    width={mbKbW}
+    height={mbKbH}
+    fill="rgba(0,0,0,0.2)"
+    rx="2"
+  />
+
+  <!-- Trackpad -->
+  <rect
+    x={mbTpX}
+    y={mbTpY}
+    width={mbTpW}
+    height={mbTpH}
+    fill="rgba(255,255,255,0.08)"
+    rx="2"
+  />
+{/if}
diff --git a/dashboard/src/lib/components/HeaderNav.svelte b/dashboard/src/lib/components/HeaderNav.svelte
index 5bdcf1ba..7f26c59d 100644
--- a/dashboard/src/lib/components/HeaderNav.svelte
+++ b/dashboard/src/lib/components/HeaderNav.svelte
@@ -6,6 +6,10 @@
   export let showSidebarToggle = false;
   export let sidebarVisible = true;
   export let onToggleSidebar: (() => void) | null = null;
+  export let downloadProgress: {
+    count: number;
+    percentage: number;
+  } | null = null;
 
   function handleHome(): void {
     if (onHome) {
@@ -33,13 +37,17 @@
     <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"
+        class="p-2 rounded border border-exo-light-gray/30 hover:border-exo-yellow/50 hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
         title={sidebarVisible ? "Hide sidebar" : "Show sidebar"}
+        aria-label={sidebarVisible
+          ? "Hide conversation sidebar"
+          : "Show conversation sidebar"}
+        aria-pressed={sidebarVisible}
       >
         <svg
           class="w-5 h-5 {sidebarVisible
             ? 'text-exo-yellow'
-            : 'text-exo-medium-gray'}"
+            : 'text-exo-light-gray'}"
           fill="none"
           viewBox="0 0 24 24"
           stroke="currentColor"
@@ -75,13 +83,14 @@
     <img
       src="/exo-logo.png"
       alt="EXO"
-      class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]"
+      class="h-18 drop-shadow-[0_0_4px_rgba(255,215,0,0.3)]"
     />
   </button>
 
   <!-- Right: Home + Downloads -->
-  <div
+  <nav
     class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4"
+    aria-label="Main navigation"
   >
     {#if showHome}
       <button
@@ -110,20 +119,56 @@
       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>
+      {#if downloadProgress}
+        <!-- Compact download progress indicator -->
+        <div class="relative w-4 h-4 flex-shrink-0">
+          <svg class="w-4 h-4 -rotate-90" viewBox="0 0 20 20">
+            <circle
+              cx="10"
+              cy="10"
+              r="8"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="2"
+              opacity="0.2"
+            />
+            <circle
+              cx="10"
+              cy="10"
+              r="8"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="2"
+              stroke-dasharray={2 * Math.PI * 8}
+              stroke-dashoffset={2 *
+                Math.PI *
+                8 *
+                (1 - downloadProgress.percentage / 100)}
+              class="text-blue-400 transition-all duration-300"
+            />
+          </svg>
+          <div
+            class="absolute inset-0 flex items-center justify-center text-[6px] font-mono text-blue-400"
+          >
+            {downloadProgress.count}
+          </div>
+        </div>
+      {:else}
+        <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>
+      {/if}
       Downloads
     </a>
-  </div>
+  </nav>
 </header>
diff --git a/dashboard/src/lib/components/ModelCard.svelte b/dashboard/src/lib/components/ModelCard.svelte
index 561c325b..b432b7a8 100644
--- a/dashboard/src/lib/components/ModelCard.svelte
+++ b/dashboard/src/lib/components/ModelCard.svelte
@@ -567,11 +567,17 @@
     <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"
+        title={sharding === "Pipeline"
+          ? "Pipeline: splits model into sequential stages across devices. Lower network overhead."
+          : "Tensor: splits each layer across devices. Best with high-bandwidth connections (Thunderbolt)."}
       >
         {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"
+        title={runtime === "MlxRing"
+          ? "Ring: standard networking. Works over any connection (Wi-Fi, Ethernet, Thunderbolt)."
+          : "RDMA: direct memory access over Thunderbolt. Significantly faster for multi-device inference."}
       >
         {runtime === "MlxRing"
           ? "MLX Ring"
@@ -581,6 +587,26 @@
       </span>
     </div>
 
+    <!-- Download Status -->
+    {#if isDownloading && progress}
+      <div class="mb-2 space-y-1">
+        <div class="flex items-center justify-between text-xs font-mono">
+          <span class="text-blue-400 tracking-wider uppercase">Downloading</span
+          >
+          <span class="text-white/60"
+            >{percentage.toFixed(1)}% &middot; {formatSpeed(progress.speed)}
+            &middot; {formatEta(progress.etaMs)}</span
+          >
+        </div>
+        <div class="h-1 bg-exo-medium-gray/30 rounded overflow-hidden">
+          <div
+            class="h-full bg-blue-500/70 transition-all duration-300"
+            style="width: {percentage}%"
+          ></div>
+        </div>
+      </div>
+    {/if}
+
     <!-- Mini Topology Preview -->
     {#if placementPreview().nodes.length > 0}
       {@const preview = placementPreview()}
diff --git a/dashboard/src/lib/components/ModelPickerModal.svelte b/dashboard/src/lib/components/ModelPickerModal.svelte
index 84a93ee7..cf21c727 100644
--- a/dashboard/src/lib/components/ModelPickerModal.svelte
+++ b/dashboard/src/lib/components/ModelPickerModal.svelte
@@ -512,6 +512,18 @@
     );
   });
 
+  // Split filtered groups into recommended (fits_now) and others for visual separation
+  const recommendedGroups = $derived(
+    filteredGroups.filter((g) =>
+      g.variants.some((v) => getModelFitStatus(v.id) === "fits_now"),
+    ),
+  );
+  const otherGroups = $derived(
+    filteredGroups.filter(
+      (g) => !g.variants.some((v) => getModelFitStatus(v.id) === "fits_now"),
+    ),
+  );
+
   function toggleGroupExpanded(groupId: string) {
     const next = new Set(expandedGroups);
     if (next.has(groupId)) {
@@ -840,7 +852,60 @@
             {/if}
           </div>
         {:else}
-          {#each filteredGroups as group}
+          <!-- Recommended for your cluster -->
+          {#if recommendedGroups.length > 0 && otherGroups.length > 0 && !searchQuery.trim()}
+            <div
+              class="sticky top-0 z-10 flex items-center gap-2 px-3 py-2 bg-green-950/60 border-b border-green-500/20 backdrop-blur-sm"
+            >
+              <svg
+                class="w-3.5 h-3.5 text-green-400 flex-shrink-0"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+                stroke-width="2"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+                />
+              </svg>
+              <span
+                class="text-xs font-mono text-green-400 tracking-wider uppercase"
+                >Recommended for your cluster</span
+              >
+              <span class="text-xs font-mono text-green-400/50"
+                >— fits in available memory</span
+              >
+            </div>
+          {/if}
+          {#each recommendedGroups as group}
+            <ModelPickerGroup
+              {group}
+              isExpanded={expandedGroups.has(group.id)}
+              isFavorite={favorites.has(group.id)}
+              {selectedModelId}
+              {canModelFit}
+              {getModelFitStatus}
+              onToggleExpand={() => toggleGroupExpanded(group.id)}
+              onSelectModel={handleSelect}
+              {onToggleFavorite}
+              onShowInfo={(g) => (infoGroup = g)}
+              downloadStatusMap={getVariantDownloadMap(group)}
+            />
+          {/each}
+          <!-- Other models -->
+          {#if otherGroups.length > 0 && recommendedGroups.length > 0 && !searchQuery.trim()}
+            <div
+              class="sticky top-0 z-10 flex items-center gap-2 px-3 py-2 bg-exo-dark-gray/80 border-y border-exo-medium-gray/20 backdrop-blur-sm"
+            >
+              <span
+                class="text-xs font-mono text-white/40 tracking-wider uppercase"
+                >Other models</span
+              >
+            </div>
+          {/if}
+          {#each otherGroups as group}
             <ModelPickerGroup
               {group}
               isExpanded={expandedGroups.has(group.id)}
diff --git a/dashboard/src/lib/components/ToastContainer.svelte b/dashboard/src/lib/components/ToastContainer.svelte
new file mode 100644
index 00000000..6bcb77b8
--- /dev/null
+++ b/dashboard/src/lib/components/ToastContainer.svelte
@@ -0,0 +1,117 @@
+<script lang="ts">
+  import { toasts, dismissToast, type Toast } from "$lib/stores/toast.svelte";
+  import { fly, fade } from "svelte/transition";
+  import { flip } from "svelte/animate";
+
+  const items = $derived(toasts());
+
+  const typeStyles: Record<
+    Toast["type"],
+    { border: string; icon: string; iconColor: string }
+  > = {
+    success: {
+      border: "border-l-green-500",
+      icon: "M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
+      iconColor: "text-green-400",
+    },
+    error: {
+      border: "border-l-red-500",
+      icon: "M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z",
+      iconColor: "text-red-400",
+    },
+    warning: {
+      border: "border-l-yellow-500",
+      icon: "M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126z",
+      iconColor: "text-yellow-400",
+    },
+    info: {
+      border: "border-l-blue-500",
+      icon: "M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z",
+      iconColor: "text-blue-400",
+    },
+  };
+</script>
+
+{#if items.length > 0}
+  <div
+    class="fixed bottom-6 right-6 z-[9999] flex flex-col gap-2 pointer-events-none"
+    role="log"
+    aria-live="polite"
+    aria-label="Notifications"
+  >
+    {#each items as toast (toast.id)}
+      {@const style = typeStyles[toast.type]}
+      <div
+        class="pointer-events-auto max-w-sm w-80 bg-exo-dark-gray/95 backdrop-blur-sm border border-exo-medium-gray/60 border-l-[3px] {style.border} rounded shadow-lg shadow-black/40"
+        in:fly={{ x: 80, duration: 250 }}
+        out:fade={{ duration: 150 }}
+        animate:flip={{ duration: 200 }}
+        role="alert"
+      >
+        <div class="flex items-start gap-3 px-4 py-3">
+          <!-- Icon -->
+          <svg
+            class="w-5 h-5 flex-shrink-0 mt-0.5 {style.iconColor}"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke-width="1.5"
+            stroke="currentColor"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              d={style.icon}
+            />
+          </svg>
+
+          <!-- Message -->
+          <p class="flex-1 text-sm text-white/90 font-mono leading-snug">
+            {toast.message}
+          </p>
+
+          <!-- Dismiss button -->
+          <button
+            onclick={() => dismissToast(toast.id)}
+            class="flex-shrink-0 p-0.5 text-white/40 hover:text-white/80 transition-colors cursor-pointer"
+            aria-label="Dismiss notification"
+          >
+            <svg
+              class="w-4 h-4"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke-width="2"
+              stroke="currentColor"
+            >
+              <path
+                stroke-linecap="round"
+                stroke-linejoin="round"
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          </button>
+        </div>
+
+        <!-- Auto-dismiss progress bar -->
+        {#if toast.duration > 0}
+          <div class="h-0.5 bg-white/5 rounded-b overflow-hidden">
+            <div
+              class="h-full {style.border.replace('border-l-', 'bg-')}/60"
+              style="animation: shrink {toast.duration}ms linear forwards"
+            ></div>
+          </div>
+        {/if}
+      </div>
+    {/each}
+  </div>
+{/if}
+
+<style>
+  @keyframes shrink {
+    from {
+      width: 100%;
+    }
+    to {
+      width: 0%;
+    }
+  }
+</style>
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
index c10c8e0a..00c6669f 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -168,7 +168,7 @@ export interface ModelDownloadStatus {
 export interface PlacementPreview {
   model_id: string;
   sharding: "Pipeline" | "Tensor";
-  instance_meta: "MlxRing" | "MlxIbv" | "MlxJaccl";
+  instance_meta: "MlxRing" | "MlxJaccl";
   instance: unknown | null;
   memory_delta_by_node: Record<string, number> | null;
   error: string | null;
@@ -219,7 +219,6 @@ interface RawStateResponse {
     string,
     {
       MlxRingInstance?: Instance;
-      MlxIbvInstance?: Instance;
       MlxJacclInstance?: Instance;
     }
   >;
@@ -590,6 +589,12 @@ class AppStore {
   // Image editing state
   editingImage = $state<EditingImage | null>(null);
 
+  /** True when the backend is reachable. */
+  isConnected = $state<boolean>(true);
+  /** Number of consecutive fetch failures. */
+  private consecutiveFailures = 0;
+  private static readonly CONNECTION_LOST_THRESHOLD = 3;
+
   private fetchInterval: ReturnType<typeof setInterval> | null = null;
   private previewsInterval: ReturnType<typeof setInterval> | null = null;
   private lastConversationPersistTs = 0;
@@ -912,11 +917,7 @@ class AppStore {
 
     let instanceType: string | null = null;
     if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
-    else if (
-      instanceTag === "MlxIbvInstance" ||
-      instanceTag === "MlxJacclInstance"
-    )
-      instanceType = "MLX RDMA";
+    else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
 
     let sharding: string | null = null;
     const inst = instance as {
@@ -1295,7 +1296,19 @@ class AppStore {
       // Thunderbolt bridge status per node
       this.nodeThunderboltBridge = data.nodeThunderboltBridge ?? {};
       this.lastUpdate = Date.now();
+      // Connection recovered
+      if (!this.isConnected) {
+        this.isConnected = true;
+      }
+      this.consecutiveFailures = 0;
     } catch (error) {
+      this.consecutiveFailures++;
+      if (
+        this.consecutiveFailures >= AppStore.CONNECTION_LOST_THRESHOLD &&
+        this.isConnected
+      ) {
+        this.isConnected = false;
+      }
       console.error("Error fetching state:", error);
     }
   }
@@ -1836,7 +1849,7 @@ class AppStore {
           assistantMessage.id,
           (msg) => {
             msg.content =
-              "Error: No model available. Please launch an instance first.";
+              "No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically.";
           },
         );
         this.syncActiveMessagesIfNeeded(targetConversationId);
@@ -2307,7 +2320,7 @@ class AppStore {
       const modelToUse = this.getModelForRequest();
       if (!modelToUse) {
         throw new Error(
-          "No model selected and no running instances available. Please launch an instance first.",
+          "No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically.",
         );
       }
 
@@ -3250,6 +3263,9 @@ export const setChatSidebarVisible = (visible: boolean) =>
   appStore.setChatSidebarVisible(visible);
 export const refreshState = () => appStore.fetchState();
 
+// Connection status
+export const isConnected = () => appStore.isConnected;
+
 // Node identities (for OS version mismatch detection)
 export const nodeIdentities = () => appStore.nodeIdentities;
 
diff --git a/dashboard/src/lib/stores/toast.svelte.ts b/dashboard/src/lib/stores/toast.svelte.ts
new file mode 100644
index 00000000..e9e62019
--- /dev/null
+++ b/dashboard/src/lib/stores/toast.svelte.ts
@@ -0,0 +1,87 @@
+/**
+ * Toast notification store - Global notification system for the EXO dashboard.
+ *
+ * Usage:
+ *   import { addToast, dismissToast, toasts } from "$lib/stores/toast.svelte";
+ *   addToast({ type: "success", message: "Model launched" });
+ *   addToast({ type: "error", message: "Connection lost", persistent: true });
+ */
+
+type ToastType = "success" | "error" | "warning" | "info";
+
+export interface Toast {
+  id: string;
+  type: ToastType;
+  message: string;
+  /** Auto-dismiss after this many ms. 0 = persistent (must be dismissed manually). */
+  duration: number;
+  createdAt: number;
+}
+
+interface ToastInput {
+  type: ToastType;
+  message: string;
+  /** If true, toast stays until manually dismissed. Default: false. */
+  persistent?: boolean;
+  /** Auto-dismiss duration in ms. Default: 4000 for success/info, 6000 for error/warning. */
+  duration?: number;
+}
+
+const DEFAULT_DURATIONS: Record<ToastType, number> = {
+  success: 4000,
+  info: 4000,
+  warning: 6000,
+  error: 6000,
+};
+
+let toastList = $state<Toast[]>([]);
+const timers = new Map<string, ReturnType<typeof setTimeout>>();
+
+function generateId(): string {
+  return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+export function addToast(input: ToastInput): string {
+  const id = generateId();
+  const duration = input.persistent
+    ? 0
+    : (input.duration ?? DEFAULT_DURATIONS[input.type]);
+
+  const toast: Toast = {
+    id,
+    type: input.type,
+    message: input.message,
+    duration,
+    createdAt: Date.now(),
+  };
+
+  toastList = [...toastList, toast];
+
+  if (duration > 0) {
+    const timer = setTimeout(() => dismissToast(id), duration);
+    timers.set(id, timer);
+  }
+
+  return id;
+}
+
+export function dismissToast(id: string): void {
+  const timer = timers.get(id);
+  if (timer) {
+    clearTimeout(timer);
+    timers.delete(id);
+  }
+  toastList = toastList.filter((t) => t.id !== id);
+}
+
+/** Dismiss all toasts matching a message (useful for dedup). */
+export function dismissByMessage(message: string): void {
+  const matching = toastList.filter((t) => t.message === message);
+  for (const t of matching) {
+    dismissToast(t.id);
+  }
+}
+
+export function toasts(): Toast[] {
+  return toastList;
+}
diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte
index d249e9cf..295c9dc0 100644
--- a/dashboard/src/routes/+layout.svelte
+++ b/dashboard/src/routes/+layout.svelte
@@ -1,5 +1,7 @@
 <script lang="ts">
   import "../app.css";
+  import ToastContainer from "$lib/components/ToastContainer.svelte";
+  import ConnectionBanner from "$lib/components/ConnectionBanner.svelte";
 
   let { children } = $props();
 </script>
@@ -10,5 +12,7 @@
 </svelte:head>
 
 <div class="min-h-screen bg-background text-foreground">
+  <ConnectionBanner />
   {@render children?.()}
+  <ToastContainer />
 </div>
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index 6e712380..7f374dd7 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -36,6 +36,7 @@
     createConversation,
     setSelectedChatModel,
     selectedChatModel,
+    sendMessage,
     debugMode,
     toggleDebugMode,
     topologyOnlyMode,
@@ -47,12 +48,16 @@
     thunderboltBridgeCycles,
     nodeThunderboltBridge,
     nodeIdentities,
+    isConnected,
     type DownloadProgress,
     type PlacementPreview,
   } from "$lib/stores/app.svelte";
+  import { addToast, dismissByMessage } from "$lib/stores/toast.svelte";
   import HeaderNav from "$lib/components/HeaderNav.svelte";
-  import { fade, fly } from "svelte/transition";
-  import { cubicInOut } from "svelte/easing";
+  import DeviceIcon from "$lib/components/DeviceIcon.svelte";
+  import { fade, fly, slide } from "svelte/transition";
+  import { tweened } from "svelte/motion";
+  import { cubicInOut, cubicOut } from "svelte/easing";
   import { onMount } from "svelte";
 
   const chatStarted = $derived(hasStartedChat());
@@ -75,6 +80,26 @@
   const rdmaCtlData = $derived(nodeRdmaCtl());
   const nodeFilter = $derived(previewNodeFilter());
 
+  // Aggregate active download progress across all instances for header indicator
+  const activeDownloadSummary = $derived.by(() => {
+    let totalBytes = 0;
+    let downloadedBytes = 0;
+    let count = 0;
+    for (const [id, inst] of Object.entries(instanceData)) {
+      const status = getInstanceDownloadStatus(id, inst);
+      if (status.isDownloading && status.progress) {
+        count++;
+        totalBytes += status.progress.totalBytes || 0;
+        downloadedBytes += status.progress.downloadedBytes || 0;
+      }
+    }
+    if (count === 0) return null;
+    return {
+      count,
+      percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+    };
+  });
+
   // Detect macOS version mismatches across cluster nodes
   const macosVersionMismatch = $derived.by(() => {
     if (!identitiesData) return null;
@@ -221,6 +246,504 @@
     "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z";
 
   let mounted = $state(false);
+  let localNodeId = $state<string | null>(null);
+
+  // ── Onboarding wizard state ──
+  const ONBOARDING_COMPLETE_KEY = "exo-onboarding-complete";
+  let onboardingStep = $state(0); // 0 = not in onboarding, 1-9 = wizard steps
+  let onboardingModelId = $state<string | null>(null); // model selected during onboarding
+  let onboardingFadingOut = $state(false); // true during fade-out transition
+  const showOnboarding = $derived(onboardingStep > 0);
+  const showOnboardingOverlay = $derived(showOnboarding || onboardingFadingOut);
+
+  // ── Steps 1-5 animation state: cinematic SVG story ──
+  const SIMULATED_STUDIO_GB = 256; // simulated Mac Studio memory
+  const onboardingCombinedGB = $derived(
+    userDeviceInfo.memoryGB + SIMULATED_STUDIO_GB,
+  );
+
+  // Models unlocked by adding the second device — one per base model, well-known preferred
+  const unlockedModels = $derived.by(() => {
+    if (models.length === 0) return [];
+    const singleGB = userDeviceInfo.memoryGB;
+    const combinedGB = onboardingCombinedGB;
+    const candidates = models
+      .filter((m) => {
+        const sizeGB = getModelSizeGB(m);
+        return sizeGB > singleGB && sizeGB <= combinedGB && m.family;
+      })
+      .sort((a, b) => getModelSizeGB(a) - getModelSizeGB(b));
+    // Deduplicate by base_model (or family as fallback) — keep smallest quant per base
+    const seen = new Set<string>();
+    const deduped: typeof candidates = [];
+    for (const m of candidates) {
+      const key = m.base_model || m.family || m.id;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      deduped.push(m);
+    }
+    return deduped.slice(0, 3);
+  });
+
+  // User device info from topology — uses /node_id to find our own node
+  const userDeviceInfo = $derived.by(() => {
+    if (!data || Object.keys(data.nodes).length === 0) {
+      return { name: "MacBook Pro", memoryGB: 36, deviceType: "macbook pro" };
+    }
+    const ourNode = localNodeId ? data.nodes[localNodeId] : undefined;
+    const node = ourNode ?? Object.values(data.nodes)[0];
+    const totalMem =
+      node.macmon_info?.memory?.ram_total ?? node.system_info?.memory ?? 0;
+    const memGB = Math.round(totalMem / (1024 * 1024 * 1024));
+    const name = node.friendly_name || "Your Mac";
+    const modelId = (node.system_info?.model_id || "macbook pro").toLowerCase();
+    return { name, memoryGB: memGB || 36, deviceType: modelId };
+  });
+
+  let showContinueButton = $state(false);
+  let stepTitle = $state("");
+  let stepTransitioning = $state(false);
+
+  // Advance to the next onboarding step
+  function advanceStep(target: number) {
+    showContinueButton = false;
+    if (target <= 5) {
+      // Steps 1-5 share a persistent SVG canvas — just set the step directly
+      onboardingStep = target;
+    } else {
+      // Leaving the cinematic sequence — fade out, then switch
+      stepTransitioning = true;
+      setTimeout(() => {
+        onboardingStep = target;
+        stepTransitioning = false;
+      }, 350);
+    }
+  }
+
+  // Tweened animation values for the persistent SVG canvas
+  const device1X = tweened(350, { duration: 800, easing: cubicInOut });
+  const device2X = tweened(550, { duration: 800, easing: cubicInOut });
+  const device2Opacity = tweened(0, { duration: 600, easing: cubicOut });
+  const connectionOpacity = tweened(0, { duration: 500, easing: cubicOut });
+  const connectionIsRed = tweened(0, { duration: 500, easing: cubicOut }); // 0=gold, 1=red
+  const combinedLabelOpacity = tweened(0, { duration: 500, easing: cubicOut });
+  const modelBlockY = tweened(20, { duration: 700, easing: cubicInOut });
+  const modelBlockOpacity = tweened(0, { duration: 500, easing: cubicOut });
+  const modelSplitProgress = tweened(0, { duration: 800, easing: cubicInOut }); // 0=unified, 1=fully split
+  const disconnectXOpacity = tweened(0, { duration: 400, easing: cubicOut });
+  const device1Opacity = tweened(1, { duration: 600, easing: cubicOut });
+  const logoOpacity = tweened(1, { duration: 600, easing: cubicOut });
+  // Step 2 chip fade: 0→N where each chip fades in at its stagger offset
+  const chipPhase = tweened(0, { duration: 800, easing: cubicOut });
+  const deviceCountOpacity = tweened(0, { duration: 600, easing: cubicOut });
+  const topologyOpacity = tweened(1, { duration: 400, easing: cubicOut });
+  const titleOpacity = tweened(0, { duration: 500, easing: cubicOut });
+  const subtitleOpacity = tweened(0, { duration: 500, easing: cubicOut });
+
+  // ── Step 1: "Your EXO Network" — show real topology ──
+  $effect(() => {
+    if (onboardingStep === 1) {
+      showContinueButton = false;
+      stepTitle = "";
+      // Reset all tweens to initial
+      device1X.set(350, { duration: 0 });
+      device1Opacity.set(0, { duration: 0 });
+      device2Opacity.set(0, { duration: 0 });
+      connectionOpacity.set(0, { duration: 0 });
+      connectionIsRed.set(0, { duration: 0 });
+      combinedLabelOpacity.set(0, { duration: 0 });
+      modelBlockOpacity.set(0, { duration: 0 });
+      modelSplitProgress.set(0, { duration: 0 });
+      disconnectXOpacity.set(0, { duration: 0 });
+      logoOpacity.set(1, { duration: 0 });
+      titleOpacity.set(0, { duration: 0 });
+      subtitleOpacity.set(0, { duration: 0 });
+      chipPhase.set(0, { duration: 0 });
+      deviceCountOpacity.set(0, { duration: 0 });
+      topologyOpacity.set(1, { duration: 0 });
+
+      const t1 = setTimeout(() => {
+        titleOpacity.set(1);
+      }, 300);
+      const t2 = setTimeout(() => {
+        deviceCountOpacity.set(1);
+      }, 800);
+      const t3 = setTimeout(() => {
+        showContinueButton = true;
+      }, 1200);
+
+      return () => {
+        clearTimeout(t1);
+        clearTimeout(t2);
+        clearTimeout(t3);
+      };
+    }
+  });
+
+  // ── Step 2: "Add devices to run larger models" — cross-fade topology out, device pair animates in ──
+  $effect(() => {
+    if (onboardingStep === 2) {
+      showContinueButton = false;
+
+      // Cross-fade: fade out real topology
+      topologyOpacity.set(0);
+
+      // Immediately transition out step 1 elements
+      logoOpacity.set(0);
+      deviceCountOpacity.set(0);
+      // Smoothly crossfade the title: fade old out, update text, fade new in
+      titleOpacity.set(0, { duration: 300 });
+      subtitleOpacity.set(0, { duration: 0 });
+
+      // Delay all step 2 animations by 400ms to let topology fade out
+      const DELAY = 400;
+
+      const t0 = setTimeout(() => {
+        stepTitle = "Add devices to run larger models";
+        titleOpacity.set(1, { duration: 400 });
+      }, DELAY + 300);
+
+      const t1 = setTimeout(() => {
+        device1Opacity.set(1, { duration: 0 });
+        device1X.set(220);
+        device2X.set(480, { duration: 0 });
+        device2Opacity.set(0, { duration: 0 });
+      }, DELAY + 200);
+      const t2 = setTimeout(() => {
+        device2Opacity.set(1);
+        device2X.set(480);
+      }, DELAY + 700);
+      const t3 = setTimeout(() => {
+        connectionOpacity.set(1);
+      }, DELAY + 1200);
+      const t4 = setTimeout(() => {
+        combinedLabelOpacity.set(1);
+      }, DELAY + 1600);
+      // Staggered chip fade-in (each chip offsets by 0.6 in chipPhase)
+      const t5 = setTimeout(() => {
+        chipPhase.set(3, { duration: 1800 });
+      }, DELAY + 1800);
+      const t6 = setTimeout(() => {
+        showContinueButton = true;
+      }, DELAY + 3200);
+
+      return () => {
+        clearTimeout(t0);
+        clearTimeout(t1);
+        clearTimeout(t2);
+        clearTimeout(t3);
+        clearTimeout(t4);
+        clearTimeout(t5);
+        clearTimeout(t6);
+      };
+    }
+  });
+
+  // ── Step 3: "exo splits the model" — model block appears, splits ──
+  $effect(() => {
+    if (onboardingStep === 3) {
+      showContinueButton = false;
+      // Gently fade out the unlock chips
+      chipPhase.set(0, { duration: 600 });
+
+      // Crossfade title
+      titleOpacity.set(0, { duration: 250 });
+      subtitleOpacity.set(0, { duration: 250 });
+      setTimeout(() => {
+        stepTitle = "exo splits models across devices";
+        titleOpacity.set(1, { duration: 400 });
+        subtitleOpacity.set(1, { duration: 400 });
+      }, 250);
+
+      // Wait for chips to fade before showing model block
+      const t1 = setTimeout(() => {
+        modelBlockOpacity.set(1);
+        modelBlockY.set(50);
+      }, 600);
+      const t2 = setTimeout(() => {
+        modelSplitProgress.set(1);
+      }, 1500);
+      const t3 = setTimeout(() => {
+        showContinueButton = true;
+      }, 2300);
+
+      return () => {
+        clearTimeout(t1);
+        clearTimeout(t2);
+        clearTimeout(t3);
+      };
+    }
+  });
+
+  // ── Step 4: "A device disconnects... exo self-heals" — full disconnect+heal sequence ──
+  $effect(() => {
+    if (onboardingStep === 4) {
+      showContinueButton = false;
+
+      // Crossfade title
+      titleOpacity.set(0, { duration: 250 });
+      subtitleOpacity.set(0, { duration: 250 });
+      setTimeout(() => {
+        stepTitle = "When a device disconnects...";
+        titleOpacity.set(1, { duration: 400 });
+        subtitleOpacity.set(1, { duration: 400 });
+      }, 250);
+
+      // Phase 1: Disconnect
+      const t1 = setTimeout(() => {
+        connectionIsRed.set(1);
+      }, 400);
+      const t2 = setTimeout(() => {
+        disconnectXOpacity.set(1);
+      }, 800);
+      const t3 = setTimeout(() => {
+        device2Opacity.set(0);
+        connectionOpacity.set(0);
+        disconnectXOpacity.set(0);
+        combinedLabelOpacity.set(0);
+      }, 1600);
+
+      // Phase 2: Self-heal — crossfade title + subtitle
+      const t4 = setTimeout(() => {
+        titleOpacity.set(0, { duration: 250 });
+        subtitleOpacity.set(0, { duration: 250 });
+      }, 2550);
+      const t4b = setTimeout(() => {
+        stepTitle = "exo self-heals";
+        titleOpacity.set(1, { duration: 400 });
+        subtitleOpacity.set(1, { duration: 400 });
+      }, 2800);
+      const t5 = setTimeout(() => {
+        device1X.set(350);
+        device2X.set(350);
+      }, 3100);
+      const t6 = setTimeout(() => {
+        modelSplitProgress.set(0);
+        modelBlockY.set(20); // Lift up while merging
+        connectionIsRed.set(0);
+      }, 3700);
+      const t7 = setTimeout(() => {
+        modelBlockY.set(125); // Settle back down just above the device
+      }, 4800);
+      const t8 = setTimeout(() => {
+        advanceStep(6);
+      }, 6200);
+
+      return () => {
+        clearTimeout(t1);
+        clearTimeout(t2);
+        clearTimeout(t3);
+        clearTimeout(t4);
+        clearTimeout(t4b);
+        clearTimeout(t5);
+        clearTimeout(t6);
+        clearTimeout(t7);
+        clearTimeout(t8);
+      };
+    }
+  });
+
+  // Recommended models for onboarding: 2 large, 2 medium, 2 small
+  // Always includes Llama-3.2-3B-4bit as a fast-loading small option
+  const PINNED_ONBOARDING_MODEL = "mlx-community/Llama-3.2-3B-Instruct-4bit";
+  const onboardingModels = $derived.by(() => {
+    if (models.length === 0) return [];
+    const sorted = [...models]
+      .filter((m) => hasEnoughMemory(m) && getModelSizeGB(m) > 0)
+      .sort((a, b) => getModelSizeGB(b) - getModelSizeGB(a));
+    if (sorted.length <= 6) return sorted;
+
+    // Split into thirds by size: large (top third), medium (middle), small (bottom)
+    const third = Math.max(1, Math.floor(sorted.length / 3));
+    const large = sorted.slice(0, third);
+    const medium = sorted.slice(third, third * 2);
+    const small = sorted.slice(third * 2);
+
+    // Pick 2 from each tier, ensuring pinned model counts as a small pick
+    const pinned =
+      small.find((m) => m.id === PINNED_ONBOARDING_MODEL) ||
+      sorted.find((m) => m.id === PINNED_ONBOARDING_MODEL);
+    const pickLarge = large.slice(0, 2);
+    const pickMedium = medium.slice(0, 2);
+    const pickSmall = pinned
+      ? [
+          small.find((m) => m.id !== PINNED_ONBOARDING_MODEL) || small[0],
+          pinned,
+        ].filter(Boolean)
+      : small.slice(0, 2);
+
+    const result = [...pickLarge, ...pickMedium, ...pickSmall];
+    // Deduplicate (in case pinned was already picked)
+    const seen = new Set<string>();
+    return result.filter((m) => {
+      if (seen.has(m.id)) return false;
+      seen.add(m.id);
+      return true;
+    });
+  });
+
+  // Track onboarding instance status for auto-advancing steps.
+  // Uses runner status as source of truth to avoid false "ready" from missing download data.
+  // Only tracks the specific model launched during onboarding (ignores other running instances).
+  $effect(() => {
+    if (onboardingStep === 7 && instanceCount > 0 && onboardingModelId) {
+      let anyDownloading = false;
+      let anyReady = false;
+      for (const [id, inst] of Object.entries(instanceData)) {
+        // Only check instances for the model we launched during onboarding
+        if (getInstanceModelId(inst) !== onboardingModelId) continue;
+        const runnerStatus = deriveInstanceStatus(inst);
+        if (
+          runnerStatus.statusText === "READY" ||
+          runnerStatus.statusText === "LOADED" ||
+          runnerStatus.statusText === "RUNNING"
+        ) {
+          anyReady = true;
+        } else if (runnerStatus.statusText === "DOWNLOADING") {
+          anyDownloading = true;
+        } else {
+          const dlStatus = getInstanceDownloadStatus(id, inst);
+          if (dlStatus.isDownloading) anyDownloading = true;
+        }
+      }
+      // Model already cached & ready — skip download AND loading steps
+      if (anyReady) {
+        onboardingStep = 9;
+      } else if (anyDownloading) {
+        // Stay on step 7 (downloading)
+      } else {
+        // Not ready and not downloading — could be loading, initializing, or preparing.
+        // Only advance to step 8 if runners are actually in a loading state.
+        for (const [, inst] of Object.entries(instanceData)) {
+          if (getInstanceModelId(inst) !== onboardingModelId) continue;
+          const runnerStatus = deriveInstanceStatus(inst);
+          if (
+            runnerStatus.statusText === "LOADING" ||
+            runnerStatus.statusText === "WARMING UP"
+          ) {
+            onboardingStep = 8;
+            break;
+          }
+        }
+      }
+    }
+  });
+
+  $effect(() => {
+    if (onboardingStep === 8 && instanceCount > 0 && onboardingModelId) {
+      for (const [, inst] of Object.entries(instanceData)) {
+        if (getInstanceModelId(inst) !== onboardingModelId) continue;
+        const runnerStatus = deriveInstanceStatus(inst);
+        if (
+          runnerStatus.statusText === "READY" ||
+          runnerStatus.statusText === "LOADED" ||
+          runnerStatus.statusText === "RUNNING"
+        ) {
+          onboardingStep = 9;
+          break;
+        }
+      }
+    }
+  });
+
+  function completeOnboarding() {
+    // Trigger fade-out, then fully remove overlay
+    onboardingFadingOut = true;
+    onboardingStep = 0;
+    try {
+      localStorage.setItem(ONBOARDING_COMPLETE_KEY, "true");
+    } catch {
+      // ignore
+    }
+    // Persist to server (~/.exo)
+    fetch("/onboarding", { method: "POST" }).catch(() => {});
+    // Remove overlay after fade-out transition completes
+    setTimeout(() => {
+      onboardingFadingOut = false;
+    }, 500);
+  }
+
+  // Auto-complete onboarding when user sends a message from step 9
+  $effect(() => {
+    if (onboardingStep === 9 && chatStarted) {
+      completeOnboarding();
+    }
+  });
+
+  let onboardingError = $state<string | null>(null);
+
+  async function onboardingLaunchModel(modelId: string) {
+    onboardingModelId = modelId;
+    onboardingError = null;
+    selectPreviewModel(modelId);
+    onboardingStep = 7;
+    // Launch via standard placement API (same as main dashboard)
+    try {
+      const placementResponse = await fetch(
+        `/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${selectedSharding}&instance_meta=${selectedInstanceType}&min_nodes=1`,
+      );
+      if (!placementResponse.ok) {
+        const errorText = await placementResponse.text();
+        onboardingError = `Failed to get placement: ${errorText}`;
+        onboardingStep = 6;
+        return;
+      }
+      const instanceData = await placementResponse.json();
+      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();
+        onboardingError = `Failed to launch: ${errorText}`;
+        onboardingStep = 6;
+        return;
+      }
+      setSelectedChatModel(modelId);
+      recordRecentLaunch(modelId);
+    } catch (error) {
+      onboardingError = `Network error: ${error}`;
+      onboardingStep = 6;
+    }
+  }
+
+  // Helper to get onboarding download progress
+  const onboardingDownloadProgress = $derived.by(() => {
+    if (instanceCount === 0) return null;
+    for (const [id, inst] of Object.entries(instanceData)) {
+      const status = getInstanceDownloadStatus(id, inst);
+      if (status.isDownloading && status.progress) {
+        return status.progress;
+      }
+    }
+    return null;
+  });
+
+  // Helper to get onboarding model loading progress (layers loaded)
+  const onboardingLoadProgress = $derived.by(() => {
+    if (instanceCount === 0 || !onboardingModelId) return null;
+    let layersLoaded = 0,
+      totalLayers = 0;
+    for (const [, inst] of Object.entries(instanceData)) {
+      if (getInstanceModelId(inst) !== onboardingModelId) continue;
+      const status = deriveInstanceStatus(inst);
+      if (
+        status.statusText === "LOADING" &&
+        status.totalLayers &&
+        status.totalLayers > 0
+      ) {
+        layersLoaded += status.layersLoaded ?? 0;
+        totalLayers += status.totalLayers;
+      }
+    }
+    if (totalLayers === 0) return null;
+    return {
+      layersLoaded,
+      totalLayers,
+      percentage: (layersLoaded / totalLayers) * 100,
+    };
+  });
 
   // Instance launch state
   let models = $state<
@@ -292,10 +815,10 @@
     return model.tasks.includes("ImageToImage");
   }
   let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
-  type InstanceMeta = "MlxRing" | "MlxIbv" | "MlxJaccl";
+  type InstanceMeta = "MlxRing" | "MlxJaccl";
 
   // Launch defaults persistence
-  const LAUNCH_DEFAULTS_KEY = "exo-launch-defaults";
+  const LAUNCH_DEFAULTS_KEY = "exo-launch-defaults-v2";
   interface LaunchDefaults {
     modelId: string | null;
     sharding: "Pipeline" | "Tensor";
@@ -337,7 +860,8 @@
 
     // Apply sharding and instance type unconditionally
     selectedSharding = defaults.sharding;
-    selectedInstanceType = defaults.instanceType;
+    selectedInstanceType =
+      defaults.instanceType === "MlxRing" ? "MlxRing" : "MlxJaccl";
 
     // Apply minNodes if valid (between 1 and maxNodes)
     if (
@@ -366,6 +890,9 @@
   // Model picker modal state
   let isModelPickerOpen = $state(false);
 
+  // Advanced options toggle (hides technical jargon for new users)
+  let showAdvancedOptions = $state(false);
+
   // Favorites state (reactive)
   const favoritesSet = $derived(getFavoritesSet());
 
@@ -549,7 +1076,7 @@
   const matchesSelectedRuntime = (runtime: InstanceMeta): boolean =>
     selectedInstanceType === "MlxRing"
       ? runtime === "MlxRing"
-      : runtime === "MlxIbv" || runtime === "MlxJaccl";
+      : runtime === "MlxJaccl";
 
   // Helper to check if a model can be launched (has valid placement with >= minNodes)
   function canModelFit(modelId: string): boolean {
@@ -692,9 +1219,43 @@
     return tags;
   });
 
-  onMount(() => {
+  onMount(async () => {
     mounted = true;
     fetchModels();
+    fetch("/node_id")
+      .then((r) => (r.ok ? r.json() : null))
+      .then((id) => {
+        if (id) localNodeId = id;
+      })
+      .catch(() => {});
+
+    // Handle reset-onboarding query parameter (triggered from native Settings)
+    const params = new URLSearchParams(window.location.search);
+    if (params.has("reset-onboarding")) {
+      localStorage.removeItem(ONBOARDING_COMPLETE_KEY);
+      window.history.replaceState({}, "", window.location.pathname);
+      onboardingStep = 1;
+      return;
+    }
+
+    // Check server-side onboarding state (persisted in ~/.exo)
+    try {
+      const res = await fetch("/onboarding");
+      if (res.ok) {
+        const data = await res.json();
+        if (!data.completed) {
+          onboardingStep = 1;
+        }
+        return;
+      }
+    } catch {
+      // Server unreachable — fall through to localStorage
+    }
+
+    // Fallback: check localStorage
+    if (!localStorage.getItem(ONBOARDING_COMPLETE_KEY)) {
+      onboardingStep = 1;
+    }
   });
 
   async function fetchModels() {
@@ -768,37 +1329,37 @@
       // Use the specific preview if provided, otherwise fall back to filtered preview
       const preview = specificPreview ?? filteredPreview();
 
-      let instanceData: unknown;
-
+      let response: Response;
       if (preview?.instance) {
-        // Use the instance from the preview
-        instanceData = preview.instance;
+        // Launch with pre-computed placement from preview
+        response = await fetch("/instance", {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ instance: 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();
+        // No preview available — use place_instance to let server decide placement
+        response = await fetch("/place_instance", {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({
+            model_id: modelId,
+            sharding: selectedSharding,
+            instance_meta: selectedInstanceType,
+            min_nodes: 1,
+          }),
+        });
       }
 
-      // 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);
+        addToast({
+          type: "error",
+          message: `Failed to launch model: ${errorText}`,
+        });
       } else {
+        addToast({ type: "info", message: `Launching model...` });
         // Always auto-select the newly launched model so the user chats to what they just launched
         setSelectedChatModel(modelId);
 
@@ -821,6 +1382,10 @@
       }
     } catch (error) {
       console.error("Error launching instance:", error);
+      addToast({
+        type: "error",
+        message: "Failed to launch model. Check console for details.",
+      });
     } finally {
       launchingModelId = null;
     }
@@ -1015,12 +1580,14 @@
     }>;
   } {
     if (!downloadsData || Object.keys(downloadsData).length === 0) {
+      // No download data yet — defer to runner status instead of assuming RUNNING
+      const statusInfo = deriveInstanceStatus(instanceWrapped);
       return {
         isDownloading: false,
         isFailed: false,
         errorMessage: null,
         progress: null,
-        statusText: "RUNNING",
+        statusText: statusInfo.statusText,
         perNode: [],
       };
     }
@@ -1201,8 +1768,10 @@
   function deriveInstanceStatus(instanceWrapped: unknown): {
     statusText: string;
     statusClass: string;
+    layersLoaded?: number;
+    totalLayers?: number;
   } {
-    const [, instance] = getTagged(instanceWrapped);
+    const [instanceTag, instance] = getTagged(instanceWrapped);
     if (!instance || typeof instance !== "object") {
       return { statusText: "PREPARING", statusClass: "inactive" };
     }
@@ -1240,8 +1809,39 @@
     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("Loading")) {
+      // Tensor parallel: each runner loads all layers — use max/min (bottleneck)
+      // Pipeline parallel: each runner loads a disjoint slice — use sum
+      const isTensor = instanceTag === "MlxJacclInstance";
+      let layersLoaded = isTensor ? Infinity : 0;
+      let totalLayers = 0;
+      for (const rid of runnerIds) {
+        const r = runnersData[rid];
+        if (!r) continue;
+        const [kind, payload] = getTagged(r);
+        if (
+          kind === "RunnerLoading" &&
+          payload &&
+          typeof payload === "object"
+        ) {
+          const p = payload as { layersLoaded?: number; totalLayers?: number };
+          if (isTensor) {
+            layersLoaded = Math.min(layersLoaded, p.layersLoaded ?? 0);
+            totalLayers = Math.max(totalLayers, p.totalLayers ?? 0);
+          } else {
+            layersLoaded += p.layersLoaded ?? 0;
+            totalLayers += p.totalLayers ?? 0;
+          }
+        }
+      }
+      if (isTensor && layersLoaded === Infinity) layersLoaded = 0;
+      return {
+        statusText: "LOADING",
+        statusClass: "starting",
+        layersLoaded,
+        totalLayers,
+      };
+    }
     if (has("WarmingUp"))
       return { statusText: "WARMING UP", statusClass: "starting" };
     if (has("Running"))
@@ -1282,6 +1882,7 @@
 
       if (!response.ok) {
         console.error("Failed to delete instance:", response.status);
+        addToast({ type: "error", message: "Failed to delete instance" });
       } 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
@@ -1353,11 +1954,7 @@
     // Instance type from tag
     let instanceType = "Unknown";
     if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
-    else if (
-      instanceTag === "MlxIbvInstance" ||
-      instanceTag === "MlxJacclInstance"
-    )
-      instanceType = "MLX RDMA";
+    else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
 
     const inst = instance as {
       shardAssignments?: {
@@ -1707,6 +2304,94 @@
   const nodeCount = $derived(data ? Object.keys(data.nodes).length : 0);
   const instanceCount = $derived(Object.keys(instanceData).length);
 
+  // ── Instance status transition toasts ──
+  // Track previous statuses so we can detect meaningful transitions and fire toasts.
+  let previousInstanceStatuses: Record<string, string> = {};
+
+  $effect(() => {
+    const currentStatuses: Record<string, string> = {};
+    for (const [id, inst] of Object.entries(instanceData)) {
+      const dlStatus = getInstanceDownloadStatus(id, inst);
+      currentStatuses[id] = dlStatus.statusText;
+    }
+
+    const prev = previousInstanceStatuses;
+
+    // Only fire toasts if we had a previous snapshot (skip the very first poll)
+    if (Object.keys(prev).length > 0) {
+      for (const [id, currentStatus] of Object.entries(currentStatuses)) {
+        const prevStatus = prev[id];
+        if (!prevStatus || prevStatus === currentStatus) continue;
+
+        const modelId = getInstanceModelId(instanceData[id]);
+        const shortName = modelId
+          ? (modelId.split("/").pop() ?? modelId)
+          : id.slice(0, 8);
+
+        // Downloading -> non-downloading, non-failure = download complete
+        if (
+          prevStatus === "DOWNLOADING" &&
+          currentStatus !== "DOWNLOADING" &&
+          currentStatus !== "FAILED"
+        ) {
+          addToast({
+            type: "success",
+            message: `Download complete: ${shortName}`,
+          });
+        }
+
+        // Loading/Warming Up -> Ready/Loaded/Running = model ready
+        if (
+          (prevStatus === "LOADING" || prevStatus === "WARMING UP") &&
+          (currentStatus === "READY" ||
+            currentStatus === "LOADED" ||
+            currentStatus === "RUNNING")
+        ) {
+          addToast({ type: "success", message: `Model ready: ${shortName}` });
+        }
+
+        // Any -> Failed
+        if (prevStatus !== "FAILED" && currentStatus === "FAILED") {
+          addToast({ type: "error", message: `Model failed: ${shortName}` });
+        }
+
+        // Any -> Shutdown
+        if (prevStatus !== "SHUTDOWN" && currentStatus === "SHUTDOWN") {
+          addToast({ type: "info", message: `Model shut down: ${shortName}` });
+        }
+      }
+    }
+
+    previousInstanceStatuses = currentStatuses;
+  });
+
+  // ── Connection status toasts ──
+  let previousConnectionStatus: boolean | null = null;
+
+  $effect(() => {
+    const connected = isConnected();
+    if (previousConnectionStatus !== null) {
+      if (previousConnectionStatus && !connected) {
+        addToast({
+          type: "warning",
+          message: "Connection to server lost",
+          persistent: true,
+        });
+      } else if (!previousConnectionStatus && connected) {
+        dismissByMessage("Connection to server lost");
+        addToast({ type: "success", message: "Connection restored" });
+      }
+    }
+    previousConnectionStatus = connected;
+  });
+
+  const suggestedPrompts = [
+    "Write a poem about the ocean",
+    "Explain quantum computing simply",
+    "Help me debug my code",
+    "Tell me a creative story",
+  ];
+
   // Helper to get the number of nodes in a placement preview
   function getPreviewNodeCount(preview: PlacementPreview): number {
     if (!preview.memory_delta_by_node) return 0;
@@ -2366,24 +3051,42 @@
   {/if}
 {/snippet}
 
-<!-- Global event listeners for slider dragging -->
+<!-- Global event listeners for slider dragging + onboarding keyboard nav -->
 <svelte:window
   onmousemove={handleSliderMouseMove}
   onmouseup={handleSliderMouseUp}
   ontouchmove={handleSliderTouchMove}
   ontouchend={handleSliderTouchEnd}
+  onkeydown={(e) => {
+    if (!showOnboardingOverlay || stepTransitioning) return;
+    if (e.key === "ArrowRight" || e.key === " " || e.key === "Enter") {
+      if (onboardingStep >= 1 && onboardingStep <= 4 && showContinueButton) {
+        e.preventDefault();
+        advanceStep(onboardingStep < 4 ? onboardingStep + 1 : 6);
+      }
+    }
+  }}
 />
 
 <div
   class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden"
 >
+  <!-- Scanline overlay -->
   <!-- Scanline overlay -->
   <div
-    class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"
+    class="fixed inset-0 pointer-events-none z-50 scanlines"
+    style="transition: opacity 0.5s ease; opacity: {showOnboardingOverlay
+      ? 0
+      : 0.2};"
   ></div>
 
-  <!-- Shooting Stars Background - one every ~15s -->
-  <div class="shooting-stars">
+  <!-- Shooting Stars Background -->
+  <div
+    class="shooting-stars"
+    style="transition: opacity 0.5s ease; opacity: {showOnboardingOverlay
+      ? 0.4
+      : 1};"
+  >
     <div
       class="shooting-star"
       style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"
@@ -2398,62 +3101,909 @@
     ></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 }}
-      >
+  {#if showOnboardingOverlay}
+    <!-- ═══════════════════════════════════════════════════════ -->
+    <!-- FULL-SCREEN ONBOARDING WIZARD (overlay)                -->
+    <!-- ═══════════════════════════════════════════════════════ -->
+    <div
+      class="absolute inset-0 flex items-center justify-center z-30 bg-exo-black"
+      style="transition: opacity 0.45s cubic-bezier(0.4, 0, 0.2, 1); opacity: {onboardingFadingOut
+        ? 0
+        : 1};"
+    >
+      {#if onboardingStep >= 1 && onboardingStep <= 4}
+        <!-- Steps 1-4: Cinematic SVG animation story -->
         <div
-          class="flex-1 relative bg-exo-dark-gray/40 rounded-lg overflow-hidden"
+          class="flex flex-col items-center w-full max-w-3xl px-8"
+          style="transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1), transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); opacity: {stepTransitioning
+            ? 0
+            : 1}; transform: scale({stepTransitioning ? 0.98 : 1});"
         >
-          <TopologyGraph
-            class="w-full h-full"
-            highlightedNodes={highlightedNodes()}
-            filteredNodes={nodeFilter}
-            onNodeClick={togglePreviewNodeFilter}
-          />
+          <!-- Logo + Step title -->
+          <div class="text-center mb-8">
+            <!-- Logo — smoothly shrinks away when leaving step 1 -->
+            <div
+              style="opacity: {$logoOpacity}; max-height: {$logoOpacity *
+                80}px; overflow: hidden; transition: max-height 0.6s cubic-bezier(0.4, 0, 0.2, 1);"
+            >
+              <img src="/exo-logo.png" alt="exo" class="w-36 mx-auto mb-10" />
+            </div>
 
-          {@render clusterWarnings()}
+            <!-- Title — single element, text updates instantly -->
+            <h1
+              class="text-2xl font-light text-white/90 tracking-wide"
+              style="opacity: {$titleOpacity}; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; letter-spacing: 0.02em;"
+            >
+              {onboardingStep === 1
+                ? "EXO connects all your devices into an AI supercomputer."
+                : stepTitle}
+            </h1>
+
+            <!-- Subtitle — uses tweened opacity, reserves space to prevent layout shift -->
+            <p
+              class="text-sm mt-2 text-white/40 max-w-md mx-auto"
+              style="opacity: {$subtitleOpacity}; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-weight: 300; min-height: 1.5em;"
+            >
+              {#if onboardingStep === 2}
+                &nbsp;
+              {:else if onboardingStep === 3}
+                The model is automatically distributed. Each device handles a
+                piece.
+              {:else if onboardingStep === 4}
+                {stepTitle === "exo self-heals"
+                  ? "exo automatically redistributes the model so inference continues without interruption."
+                  : "Devices can leave anytime. Laptops close, machines restart."}
+              {:else}
+                &nbsp;
+              {/if}
+            </p>
+          </div>
 
-          <!-- TB5 RDMA Not Enabled Warning -->
-          {#if tb5WithoutRdma && !tb5InfoDismissed}
-            <div
-              class="absolute left-4 group"
-              class:top-16={tbBridgeCycles.length > 0}
-              class:top-4={tbBridgeCycles.length === 0}
-              role="status"
+          <!-- Device display area -->
+          <div class="relative w-full" style="height: 420px;">
+            <!-- Device count label — fades in on step 1, fades out on step 2 -->
+            <p
+              class="absolute left-0 right-0 text-center text-lg text-white/50 font-light tracking-wide z-10"
+              style="top: 20px; opacity: {$deviceCountOpacity}; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; pointer-events: none;"
             >
+              Your EXO Network
+            </p>
+
+            <!-- Step 1: Real topology graph -->
+            {#if onboardingStep <= 1 || $topologyOpacity > 0.01}
               <div
-                class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
+                class="absolute inset-0 flex items-center justify-center"
+                style="opacity: {$topologyOpacity}; pointer-events: {onboardingStep <=
+                1
+                  ? 'none'
+                  : 'none'};"
               >
-                <svg
-                  class="w-5 h-5 text-yellow-400 flex-shrink-0"
-                  fill="none"
-                  viewBox="0 0 24 24"
-                  stroke="currentColor"
-                  stroke-width="2"
+                <TopologyGraph class="w-full h-full" />
+              </div>
+            {/if}
+
+            <!-- Steps 2+: Tweened SVG canvas with device pair -->
+            <svg
+              viewBox="0 0 700 420"
+              class="w-full h-full"
+              xmlns="http://www.w3.org/2000/svg"
+              style="position: relative;"
+            >
+              <!-- Device 1 (User's device) -->
+              <g
+                transform="translate({$device1X}, 210)"
+                opacity={$device1Opacity}
+                style="transition: opacity 0.6s ease;"
+              >
+                <DeviceIcon
+                  deviceType={userDeviceInfo.deviceType}
+                  cx={0}
+                  cy={0}
+                  size={110}
+                  ramPercent={60}
+                  uid="onb-d1"
+                />
+                <text
+                  x="0"
+                  y="-105"
+                  text-anchor="middle"
+                  fill="rgba(255,255,255,0.9)"
+                  style="font-size: 15px; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-weight: 500; letter-spacing: 0.01em;"
                 >
-                  <path
+                  {userDeviceInfo.name}
+                </text>
+                <text
+                  x="0"
+                  y="105"
+                  text-anchor="middle"
+                  style="font-size: 14px; font-family: 'SF Mono', ui-monospace, monospace;"
+                >
+                  <tspan fill="rgba(255,215,0,0.9)"
+                    >{userDeviceInfo.memoryGB}</tspan
+                  ><tspan fill="rgba(255,255,255,0.4)">{" "}GB</tspan>
+                </text>
+              </g>
+
+              <!-- Device 2 (Mac Studio — simulated) -->
+              <g
+                transform="translate({$device2X}, 210)"
+                opacity={$device2Opacity}
+                style="transition: opacity 0.6s ease;"
+              >
+                <!-- Dashed outline to indicate simulated device -->
+                <rect
+                  x={(-110 * 1.25) / 2 - 6}
+                  y={(-110 * 0.85) / 2 - 6}
+                  width={110 * 1.25 + 12}
+                  height={110 * 0.85 + 12}
+                  rx="6"
+                  fill="none"
+                  stroke="rgba(255,255,255,0.12)"
+                  stroke-dasharray="4,4"
+                />
+                <DeviceIcon
+                  deviceType="mac studio"
+                  cx={0}
+                  cy={0}
+                  size={110}
+                  ramPercent={80}
+                  uid="onb-d2"
+                />
+                <text
+                  x="0"
+                  y="-105"
+                  text-anchor="middle"
+                  fill="rgba(255,255,255,0.9)"
+                  style="font-size: 15px; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-weight: 500; letter-spacing: 0.01em;"
+                >
+                  Mac Studio
+                </text>
+                <text
+                  x="0"
+                  y="105"
+                  text-anchor="middle"
+                  style="font-size: 14px; font-family: 'SF Mono', ui-monospace, monospace;"
+                >
+                  <tspan fill="rgba(255,215,0,0.9)">{SIMULATED_STUDIO_GB}</tspan
+                  ><tspan fill="rgba(255,255,255,0.4)">{" "}GB</tspan>
+                </text>
+                <text
+                  x="0"
+                  y="120"
+                  text-anchor="middle"
+                  fill="rgba(255,255,255,0.2)"
+                  style="font-size: 9px; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-style: italic;"
+                >
+                  (example)
+                </text>
+              </g>
+
+              <!-- Connection line between devices -->
+              <line
+                x1={$device1X + 85}
+                y1={210}
+                x2={$device2X - 85}
+                y2={210}
+                stroke={$connectionIsRed > 0.5
+                  ? "rgba(220,38,38,0.7)"
+                  : "rgba(255,255,255,0.15)"}
+                stroke-width="1.5"
+                stroke-dasharray="6,6"
+                opacity={$connectionOpacity}
+                class={$connectionIsRed > 0.5
+                  ? "onboarding-connection-line-red"
+                  : "onboarding-connection-line"}
+              />
+
+              <!-- Disconnect X mark -->
+              {#if $disconnectXOpacity > 0.01}
+                <g
+                  transform="translate({($device1X + $device2X) / 2}, 210)"
+                  opacity={$disconnectXOpacity}
+                >
+                  <circle
+                    r="18"
+                    fill="rgba(220,38,38,0.1)"
+                    stroke="rgba(220,38,38,0.6)"
+                    stroke-width="1.5"
+                  />
+                  <line
+                    x1="-8"
+                    y1="-8"
+                    x2="8"
+                    y2="8"
+                    stroke="rgba(220,38,38,0.8)"
+                    stroke-width="2.5"
+                    stroke-linecap="round"
+                  />
+                  <line
+                    x1="8"
+                    y1="-8"
+                    x2="-8"
+                    y2="8"
+                    stroke="rgba(220,38,38,0.8)"
+                    stroke-width="2.5"
+                    stroke-linecap="round"
+                  />
+                </g>
+              {/if}
+
+              <!-- Combined memory label -->
+              <text
+                x={($device1X + $device2X) / 2}
+                y={130}
+                text-anchor="middle"
+                fill="rgba(255,215,0,0.7)"
+                style="font-size: 14px; font-family: 'SF Mono', ui-monospace, monospace; font-weight: 500; letter-spacing: 0.02em;"
+                opacity={$combinedLabelOpacity}
+              >
+                {onboardingCombinedGB} GB combined
+              </text>
+
+              <!-- Step 2: Models unlocked — staggered slide-up + yellow glow -->
+              {#if unlockedModels.length > 0 && $chipPhase > 0.01}
+                {@const centerX = ($device1X + $device2X) / 2}
+                {@const chipW = 140}
+                {@const chipH = 30}
+                {@const chipGap = 12}
+                {@const totalW =
+                  unlockedModels.length * chipW +
+                  (unlockedModels.length - 1) * chipGap}
+                {@const startX = centerX - totalW / 2}
+                <!-- SVG filter for yellow glow -->
+                <defs>
+                  <filter
+                    id="chip-glow"
+                    x="-50%"
+                    y="-50%"
+                    width="200%"
+                    height="200%"
+                  >
+                    <feGaussianBlur
+                      in="SourceGraphic"
+                      stdDeviation="4"
+                      result="blur"
+                    />
+                    <feColorMatrix
+                      in="blur"
+                      type="matrix"
+                      values="1 0.8 0 0 0  0.8 0.7 0 0 0  0 0 0 0 0  0 0 0 0.4 0"
+                      result="glow"
+                    />
+                    <feMerge>
+                      <feMergeNode in="glow" />
+                      <feMergeNode in="SourceGraphic" />
+                    </feMerge>
+                  </filter>
+                </defs>
+                <!-- Header slides up + fades with yellow tint -->
+                {@const headerProgress = Math.min(1, $chipPhase)}
+                {@const headerY = 332 + 12 * (1 - headerProgress)}
+                {@const yellowR = 234}
+                {@const yellowG = 179}
+                {@const yellowB = 8}
+                <text
+                  x={centerX}
+                  y={headerY}
+                  text-anchor="middle"
+                  dominant-baseline="middle"
+                  fill="rgba({yellowR},{yellowG},{yellowB},{0.5 *
+                    headerProgress})"
+                  opacity={headerProgress}
+                  style="font-size: 10px; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-weight: 500; letter-spacing: 0.1em;"
+                >
+                  NEW MODELS UNLOCKED
+                </text>
+                <!-- Model chips — staggered slide-up + scale + yellow highlight -->
+                {#each unlockedModels as model, i}
+                  {@const stagger = i * 0.6}
+                  {@const progress = Math.max(
+                    0,
+                    Math.min(1, $chipPhase - stagger),
+                  )}
+                  {@const modelName = (
+                    model.name ||
+                    model.id.split("/").pop() ||
+                    ""
+                  ).slice(0, 18)}
+                  {@const modelSize = Math.round(getModelSizeGB(model))}
+                  {@const slideY = 16 * (1 - progress)}
+                  {@const chipScale = 0.85 + 0.15 * progress}
+                  <!-- Yellow highlight peaks at ~0.6 progress then settles to subtle -->
+                  {@const highlightPeak =
+                    progress < 0.6
+                      ? progress / 0.6
+                      : 1 - ((progress - 0.6) / 0.4) * 0.6}
+                  {@const borderYellow = 0.15 + 0.35 * highlightPeak}
+                  {@const fillYellow = 0.02 + 0.06 * highlightPeak}
+                  {#if progress > 0}
+                    <g
+                      transform="translate({startX +
+                        i * (chipW + chipGap) +
+                        chipW / 2}, {358 + slideY}) scale({chipScale})"
+                      opacity={progress}
+                      filter={highlightPeak > 0.3 ? "url(#chip-glow)" : "none"}
+                    >
+                      <rect
+                        x={-chipW / 2}
+                        y={-chipH / 2}
+                        width={chipW}
+                        height={chipH}
+                        rx="15"
+                        fill="rgba({yellowR},{yellowG},{yellowB},{fillYellow})"
+                        stroke="rgba({yellowR},{yellowG},{yellowB},{borderYellow})"
+                        stroke-width="1"
+                      />
+                      <text
+                        x="0"
+                        y={modelSize ? -4 : 1}
+                        text-anchor="middle"
+                        dominant-baseline="middle"
+                        fill="rgba(255,255,255,{0.5 + 0.3 * progress})"
+                        style="font-size: 10px; font-family: 'SF Mono', ui-monospace, monospace; font-weight: 500;"
+                      >
+                        {modelName}
+                      </text>
+                      {#if modelSize}
+                        <text
+                          x="0"
+                          y="8"
+                          text-anchor="middle"
+                          dominant-baseline="middle"
+                          fill="rgba(255,255,255,{0.15 + 0.15 * progress})"
+                          style="font-size: 8px; font-family: 'SF Mono', ui-monospace, monospace; font-weight: 400;"
+                        >
+                          {modelSize} GB
+                        </text>
+                      {/if}
+                    </g>
+                  {/if}
+                {/each}
+              {/if}
+
+              <!-- Model block (unified or split) -->
+              {#if $modelBlockOpacity > 0.01}
+                {#if $modelSplitProgress < 0.05}
+                  <!-- Unified model block — centers on device1 when device2 is hidden -->
+                  {@const modelCenterX =
+                    $device2Opacity > 0.3
+                      ? ($device1X + $device2X) / 2
+                      : $device1X}
+                  <g
+                    transform="translate({modelCenterX}, {$modelBlockY})"
+                    opacity={$modelBlockOpacity}
+                  >
+                    <rect
+                      x="-45"
+                      y="-13"
+                      width="90"
+                      height="26"
+                      rx="6"
+                      fill="rgba(180,140,0,0.08)"
+                      stroke="rgba(180,140,0,0.45)"
+                      stroke-width="1.5"
+                    />
+                    <text
+                      x="0"
+                      y="5"
+                      text-anchor="middle"
+                      fill="rgba(220,180,40,0.9)"
+                      style="font-size: 12px; font-family: -apple-system, system-ui, sans-serif; font-weight: 500;"
+                    >
+                      LLM
+                    </text>
+                  </g>
+                {:else}
+                  <!-- Split model halves flowing down to each device -->
+                  {@const splitX =
+                    $modelSplitProgress * (($device2X - $device1X) / 2)}
+                  {@const centerX = ($device1X + $device2X) / 2}
+                  {@const splitY = $modelBlockY + $modelSplitProgress * 80}
+
+                  <!-- Left half -> Device 1 -->
+                  <g
+                    transform="translate({centerX - splitX}, {splitY})"
+                    opacity={$modelBlockOpacity}
+                  >
+                    <rect
+                      x="-45"
+                      y="-13"
+                      width="90"
+                      height="26"
+                      rx="6"
+                      fill="rgba(180,140,0,0.08)"
+                      stroke="rgba(180,140,0,0.35)"
+                      stroke-width="1"
+                    />
+                    <text
+                      x="0"
+                      y="4"
+                      text-anchor="middle"
+                      fill="rgba(220,180,40,0.75)"
+                      style="font-size: 11px; font-family: -apple-system, system-ui, sans-serif;"
+                    >
+                      Shard 1/2
+                    </text>
+                  </g>
+
+                  <!-- Right half -> Device 2 -->
+                  <g
+                    transform="translate({centerX + splitX}, {splitY})"
+                    opacity={$modelBlockOpacity * $device2Opacity}
+                  >
+                    <rect
+                      x="-45"
+                      y="-13"
+                      width="90"
+                      height="26"
+                      rx="6"
+                      fill="rgba(180,140,0,0.08)"
+                      stroke="rgba(180,140,0,0.35)"
+                      stroke-width="1"
+                    />
+                    <text
+                      x="0"
+                      y="4"
+                      text-anchor="middle"
+                      fill="rgba(220,180,40,0.75)"
+                      style="font-size: 11px; font-family: -apple-system, system-ui, sans-serif;"
+                    >
+                      Shard 2/2
+                    </text>
+                  </g>
+                {/if}
+              {/if}
+            </svg>
+          </div>
+
+          <!-- Continue button — smooth transition, only for steps 1 and 5 -->
+          <div
+            style="transition: opacity 0.4s ease, transform 0.4s cubic-bezier(0.4,0,0.2,1); opacity: {showContinueButton
+              ? 1
+              : 0}; transform: translateY({showContinueButton
+              ? '0px'
+              : '12px'}); pointer-events: {showContinueButton
+              ? 'auto'
+              : 'none'}; margin-top: 0.5rem;"
+          >
+            <button
+              type="button"
+              onclick={() =>
+                advanceStep(onboardingStep < 4 ? onboardingStep + 1 : 6)}
+              class="inline-flex items-center gap-2.5 px-10 py-3.5 bg-exo-yellow text-exo-black text-sm font-semibold rounded-full cursor-pointer"
+              style="transition: transform 0.2s ease, box-shadow 0.3s ease, filter 0.2s ease; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; letter-spacing: 0.02em;"
+              onmouseenter={(e) => {
+                e.currentTarget.style.filter = "brightness(1.08)";
+                e.currentTarget.style.boxShadow =
+                  "0 0 30px rgba(255,215,0,0.2)";
+              }}
+              onmouseleave={(e) => {
+                e.currentTarget.style.filter = "brightness(1)";
+                e.currentTarget.style.boxShadow = "none";
+              }}
+            >
+              Continue
+              <svg
+                class="w-4 h-4"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+                stroke-width="2.5"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M13 7l5 5m0 0l-5 5m5-5H6"
+                />
+              </svg>
+            </button>
+          </div>
+        </div>
+      {:else if onboardingStep === 6}
+        <!-- Step 6: Choose a Model -->
+        <div
+          class="flex flex-col items-center w-full max-w-2xl px-8"
+          style="opacity: 0; animation: onb-fade-in 0.5s ease forwards;"
+        >
+          <div class="text-center mb-8">
+            <h1
+              class="text-xl font-sans font-light text-white/90 mb-2 tracking-wide"
+            >
+              Choose a model
+            </h1>
+            <p class="text-sm font-sans text-white/40">
+              Showing recommended models for your devices ({Math.round(
+                clusterMemory().total / (1024 * 1024 * 1024),
+              )} GB memory available).
+            </p>
+          </div>
+
+          {#if onboardingError}
+            <div
+              class="w-full mb-6 px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-sm font-mono text-red-400"
+              in:fade={{ duration: 200 }}
+            >
+              {onboardingError}
+            </div>
+          {/if}
+
+          {#if onboardingModels.length === 0}
+            <div class="text-center py-8">
+              <div class="text-sm text-white/40 font-sans animate-pulse">
+                Loading models...
+              </div>
+            </div>
+          {:else}
+            <div class="w-full space-y-3 mb-8">
+              {#each onboardingModels as model}
+                {@const sizeGB = getModelSizeGB(model)}
+                {@const fitsNow = hasEnoughMemory(model)}
+                {@const tags = modelTags()[model.id] || []}
+                <button
+                  type="button"
+                  onclick={() => onboardingLaunchModel(model.id)}
+                  class="w-full flex items-center justify-between gap-4 px-5 py-4 rounded-xl border transition-all duration-200 cursor-pointer {fitsNow
+                    ? 'border-white/10 bg-white/5 hover:border-exo-yellow/50 hover:bg-exo-yellow/5'
+                    : 'border-white/10 bg-white/[0.02] hover:border-white/20 opacity-60'}"
+                >
+                  <div class="flex flex-col items-start gap-1 min-w-0">
+                    <div class="flex items-center gap-2">
+                      <span
+                        class="text-sm font-sans font-medium text-white truncate"
+                        >{model.name || model.id}</span
+                      >
+                      {#each tags as tag}
+                        <span
+                          class="text-[10px] font-sans font-medium px-1.5 py-0.5 rounded-full bg-exo-yellow/10 text-exo-yellow/80"
+                          >{tag}</span
+                        >
+                      {/each}
+                    </div>
+                    <span class="text-xs font-mono text-white/40 truncate"
+                      >{model.id}</span
+                    >
+                  </div>
+                  <div class="flex items-center gap-3 flex-shrink-0">
+                    <span class="text-xs font-mono text-white/50"
+                      >{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)} GB</span
+                    >
+                    <svg
+                      class="w-4 h-4 text-white/40"
+                      fill="none"
+                      viewBox="0 0 24 24"
+                      stroke="currentColor"
+                      stroke-width="2"
+                    >
+                      <path
+                        stroke-linecap="round"
+                        stroke-linejoin="round"
+                        d="M9 5l7 7-7 7"
+                      />
+                    </svg>
+                  </div>
+                </button>
+              {/each}
+            </div>
+          {/if}
+
+          <button
+            type="button"
+            onclick={() => {
+              isModelPickerOpen = true;
+            }}
+            class="text-sm font-sans text-white/40 hover:text-exo-yellow transition-colors cursor-pointer underline underline-offset-4 decoration-white/20 hover:decoration-exo-yellow/50"
+          >
+            Browse all models
+          </button>
+        </div>
+      {:else if onboardingStep === 7}
+        <!-- Step 7: Downloading -->
+        <div
+          class="text-center max-w-lg px-8"
+          style="opacity: 0; animation: onb-fade-in 0.5s ease forwards;"
+        >
+          <div class="mb-8">
+            <h1
+              class="text-xl font-sans font-light text-white/90 mb-2 tracking-wide"
+            >
+              Downloading
+            </h1>
+            {#if onboardingModelId}
+              <p class="text-sm text-white/40 font-sans">
+                {onboardingModelId.split("/").pop() ?? onboardingModelId}
+              </p>
+            {/if}
+          </div>
+
+          {#if onboardingDownloadProgress}
+            <div class="w-full max-w-md mx-auto space-y-4">
+              <div
+                class="relative h-2 bg-white/10 rounded-full overflow-hidden"
+              >
+                <div
+                  class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-exo-yellow-darker rounded-full transition-all duration-500"
+                  style="width: {onboardingDownloadProgress.percentage}%"
+                ></div>
+              </div>
+              <div class="flex justify-between text-xs font-mono text-white/50">
+                <span>{onboardingDownloadProgress.percentage.toFixed(1)}%</span>
+                <span
+                  >{formatBytes(onboardingDownloadProgress.downloadedBytes)} /
+                  {formatBytes(onboardingDownloadProgress.totalBytes)}</span
+                >
+              </div>
+              <div class="flex justify-between text-xs font-mono text-white/40">
+                <span>{formatSpeed(onboardingDownloadProgress.speed)}</span>
+                <span>ETA: {formatEta(onboardingDownloadProgress.etaMs)}</span>
+              </div>
+            </div>
+          {:else}
+            <div class="w-full max-w-md mx-auto">
+              <div
+                class="relative h-2 bg-white/10 rounded-full overflow-hidden"
+              >
+                <div
+                  class="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-exo-yellow to-exo-yellow-darker rounded-full animate-pulse"
+                ></div>
+              </div>
+              <p class="text-xs font-mono text-white/40 mt-4">
+                Preparing download...
+              </p>
+            </div>
+          {/if}
+
+          <p class="text-xs font-sans text-white/40 mt-8">
+            This may take a few minutes depending on your connection.
+          </p>
+        </div>
+      {:else if onboardingStep === 8}
+        <!-- Step 8: Loading into memory -->
+        <div
+          class="text-center max-w-lg px-8"
+          style="opacity: 0; animation: onb-fade-in 0.5s ease forwards;"
+        >
+          <div class="mb-6">
+            <h1
+              class="text-xl font-sans font-light text-white/90 mb-2 tracking-wide"
+            >
+              Loading into memory
+            </h1>
+            {#if onboardingModelId}
+              <p class="text-sm text-white/40 font-sans">
+                {onboardingModelId.split("/").pop() ?? onboardingModelId}
+              </p>
+            {/if}
+          </div>
+
+          <!-- Device icon -->
+          <div class="flex justify-center mb-6">
+            <svg
+              viewBox="0 0 200 200"
+              class="w-32 h-32"
+              xmlns="http://www.w3.org/2000/svg"
+            >
+              <DeviceIcon
+                deviceType={userDeviceInfo.deviceType}
+                cx={100}
+                cy={100}
+                size={80}
+                ramPercent={60}
+                uid="onb-loading"
+              />
+            </svg>
+          </div>
+
+          {#if onboardingLoadProgress}
+            <div class="w-full max-w-xs mx-auto space-y-3">
+              <div
+                class="relative h-2 bg-white/10 rounded-full overflow-hidden"
+              >
+                <div
+                  class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-exo-yellow-darker rounded-full transition-all duration-500"
+                  style="width: {onboardingLoadProgress.percentage}%"
+                ></div>
+              </div>
+              <p class="text-xs text-white/40 font-mono text-center">
+                {onboardingLoadProgress.layersLoaded} / {onboardingLoadProgress.totalLayers}
+                layers loaded
+              </p>
+            </div>
+          {:else}
+            <div class="flex justify-center mb-4">
+              <div
+                class="w-8 h-8 border-2 border-exo-yellow/15 border-t-exo-yellow/70 rounded-full animate-spin"
+              ></div>
+            </div>
+            <p class="text-sm text-white/30 font-sans">Loading...</p>
+          {/if}
+        </div>
+      {:else if onboardingStep === 9}
+        <!-- Step 9: Ready — centered input with suggestion chips -->
+        <!-- Uses onb-fade-opacity (no transform) so fixed-position dropdown in ChatForm works correctly -->
+        <div
+          class="flex flex-col items-center justify-center w-full max-w-2xl px-8"
+          style="opacity: 0; animation: onb-fade-opacity 0.6s ease forwards;"
+        >
+          <img
+            src="/exo-logo.png"
+            alt="exo"
+            class="w-28 mb-6"
+            style="opacity: 0.8;"
+          />
+
+          {#if onboardingModelId}
+            <p class="text-sm text-white/40 font-mono mb-6">
+              {onboardingModelId.split("/").pop() ?? onboardingModelId}
+            </p>
+          {/if}
+
+          <div class="w-full">
+            <ChatForm
+              placeholder="Ask anything"
+              autofocus={true}
+              showHelperText={false}
+              showModelSelector={true}
+              modelTasks={modelTasks()}
+              modelCapabilities={modelCapabilities()}
+            />
+          </div>
+
+          <div class="flex flex-wrap justify-center gap-3 mt-6">
+            {#each suggestedPrompts as chip}
+              <button
+                type="button"
+                onclick={() => {
+                  completeOnboarding();
+                  sendMessage(chip);
+                }}
+                class="px-4 py-2 rounded-full border border-white/10 bg-white/5 text-sm text-white/60 hover:bg-white/10 hover:text-white/80 hover:border-white/20 transition-all duration-200 cursor-pointer"
+              >
+                {chip}
+              </button>
+            {/each}
+          </div>
+        </div>
+      {/if}
+
+      <!-- Replay / Skip — visible on all onboarding steps -->
+      <div class="absolute bottom-8 flex items-center gap-6">
+        <button
+          type="button"
+          onclick={() => {
+            onboardingStep = 0;
+            setTimeout(() => {
+              onboardingStep = 1;
+            }, 50);
+          }}
+          class="flex items-center gap-1.5 text-xs font-sans text-white/15 hover:text-white/35 transition-colors duration-300 cursor-pointer"
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 16 16"
+            fill="none"
+            stroke="currentColor"
+            stroke-width="1.8"
+            stroke-linecap="round"
+            stroke-linejoin="round"
+          >
+            <path d="M2.5 2v5h5" />
+            <path d="M2.5 7a6.5 6.5 0 1 1 1.4-2.8" />
+          </svg>
+          Replay
+        </button>
+        <button
+          type="button"
+          onclick={completeOnboarding}
+          class="flex items-center gap-1.5 text-xs font-sans text-white/15 hover:text-white/35 transition-colors duration-300 cursor-pointer"
+        >
+          <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
+            <path d="M3 2.5v11L9 8 3 2.5z" />
+            <rect x="10.5" y="2.5" width="2.5" height="11" rx="0.5" />
+          </svg>
+          Skip
+        </button>
+      </div>
+    </div>
+
+    <!-- Model Picker Modal (available during onboarding step 4) -->
+    {#if onboardingStep === 6}
+      <ModelPickerModal
+        isOpen={isModelPickerOpen}
+        {models}
+        {selectedModelId}
+        favorites={favoritesSet}
+        {recentModelIds}
+        hasRecents={showRecentsTab}
+        existingModelIds={new Set(models.map((m) => m.id))}
+        canModelFit={(modelId) => {
+          const model = models.find((m) => m.id === modelId);
+          return model ? hasEnoughMemory(model) : false;
+        }}
+        getModelFitStatus={(modelId): ModelMemoryFitStatus => {
+          const model = models.find((m) => m.id === modelId);
+          return model ? getModelMemoryFitStatus(model) : "too_large";
+        }}
+        onSelect={(modelId) => {
+          isModelPickerOpen = false;
+          onboardingLaunchModel(modelId);
+        }}
+        onClose={() => (isModelPickerOpen = false)}
+        onToggleFavorite={toggleFavorite}
+        onAddModel={addModelFromPicker}
+        onDeleteModel={deleteCustomModel}
+        totalMemoryGB={clusterMemory().total / (1024 * 1024 * 1024)}
+        usedMemoryGB={clusterMemory().used / (1024 * 1024 * 1024)}
+        {downloadsData}
+        topologyNodes={data?.nodes}
+      />
+    {/if}
+  {/if}
+
+  <!-- ═══════════════════════════════════════════════════════ -->
+  <!-- MAIN DASHBOARD (always rendered, behind onboarding)    -->
+  <!-- ═══════════════════════════════════════════════════════ -->
+  {#if !topologyOnlyEnabled}
+    <HeaderNav
+      showHome={true}
+      onHome={handleGoHome}
+      showSidebarToggle={true}
+      {sidebarVisible}
+      onToggleSidebar={toggleChatSidebarVisible}
+      downloadProgress={activeDownloadSummary}
+    />
+  {/if}
+
+  <!-- Main Content -->
+  <main class="flex-1 flex overflow-hidden relative">
+    <!-- Left: Conversation History Sidebar (hidden in topology-only mode, welcome state, or when toggled off) -->
+    {#if !topologyOnlyEnabled && sidebarVisible}
+      <div
+        class="w-80 flex-shrink-0 border-r border-exo-yellow/10"
+        role="complementary"
+        aria-label="Conversation history"
+      >
+        <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()}
+            filteredNodes={nodeFilter}
+            onNodeClick={togglePreviewNodeFilter}
+          />
+
+          {@render clusterWarnings()}
+
+          <!-- TB5 RDMA Not Enabled Warning -->
+          {#if tb5WithoutRdma && !tb5InfoDismissed}
+            <div
+              class="absolute left-4 group"
+              class:top-16={tbBridgeCycles.length > 0}
+              class:top-4={tbBridgeCycles.length === 0}
+              role="status"
+            >
+              <div
+                class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
+              >
+                <svg
+                  class="w-5 h-5 text-yellow-400 flex-shrink-0"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                  stroke-width="2"
+                >
+                  <path
                     stroke-linecap="round"
                     stroke-linejoin="round"
                     d={warningIconPath}
@@ -2520,6 +4070,7 @@
             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"
+            aria-label="Exit topology only mode"
           >
             <svg
               class="w-5 h-5 text-exo-yellow"
@@ -2539,7 +4090,7 @@
     {:else if !chatStarted}
       <!-- WELCOME STATE: Topology + Instance Controls (no left sidebar for cleaner look) -->
       <div
-        class="flex-1 flex overflow-visible relative"
+        class="flex-1 flex overflow-hidden relative"
         in:fade={{ duration: 300 }}
         out:fade={{ duration: 200 }}
       >
@@ -2557,6 +4108,26 @@
               onNodeClick={togglePreviewNodeFilter}
             />
 
+            <!-- Initial loading state before first data fetch -->
+            {#if !update}
+              <div
+                class="absolute inset-0 flex items-center justify-center bg-exo-dark-gray/80"
+                in:fade={{ duration: 200 }}
+                out:fade={{ duration: 300 }}
+              >
+                <div class="text-center">
+                  <div
+                    class="w-8 h-8 border-2 border-exo-yellow/30 border-t-exo-yellow rounded-full animate-spin mx-auto mb-4"
+                  ></div>
+                  <p
+                    class="text-xs font-mono text-white/40 tracking-wider uppercase"
+                  >
+                    Connecting to cluster&hellip;
+                  </p>
+                </div>
+              </div>
+            {/if}
+
             {@render clusterWarnings()}
 
             <!-- TB5 RDMA Not Enabled Warning -->
@@ -2667,11 +4238,20 @@
             {/if}
           </div>
 
-          <!-- Chat Input - Below topology -->
-          <div class="px-4 pt-6 pb-8">
+          <!-- Chat Input - Below topology, never overlaps -->
+          <div class="px-4 pt-4 pb-6 flex-shrink-0">
             <div class="max-w-3xl mx-auto">
+              {#if instanceCount === 0}
+                <div class="text-center mb-4">
+                  <p class="text-sm text-white/50 font-sans">
+                    Select a model to get started.
+                  </p>
+                </div>
+              {/if}
               <ChatForm
-                placeholder="Ask anything"
+                placeholder={instanceCount === 0
+                  ? "Choose a model to start chatting"
+                  : "Ask anything"}
                 showHelperText={false}
                 showModelSelector={true}
                 modelTasks={modelTasks()}
@@ -2684,6 +4264,7 @@
         <!-- 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"
+          aria-label="Instance controls"
         >
           <!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
           {#if instanceCount > 0}
@@ -2715,10 +4296,9 @@
                   {@const statusText = downloadInfo.statusText}
                   {@const isDownloading = downloadInfo.isDownloading}
                   {@const isFailed = statusText === "FAILED"}
-                  {@const isLoading =
-                    statusText === "LOADING" ||
-                    statusText === "WARMING UP" ||
-                    statusText === "WAITING"}
+                  {@const isLoading = statusText === "LOADING"}
+                  {@const isWarmingUp =
+                    statusText === "WARMING UP" || statusText === "WAITING"}
                   {@const isReady =
                     statusText === "READY" || statusText === "LOADED"}
                   {@const isRunning = statusText === "RUNNING"}
@@ -2731,6 +4311,7 @@
                     class="relative group cursor-pointer"
                     role="button"
                     tabindex="0"
+                    transition:slide={{ duration: 250, easing: cubicOut }}
                     onmouseenter={() => (hoveredInstanceId = id)}
                     onmouseleave={() => (hoveredInstanceId = null)}
                     onclick={() => {
@@ -2801,15 +4382,15 @@
                     ></div>
 
                     <div
-                      class="bg-exo-dark-gray/60 border border-l-2 {isDownloading
-                        ? 'border-blue-500/30 border-l-blue-400'
+                      class="bg-exo-dark-gray/60 border border-l-2 transition-all duration-200 group-hover:bg-exo-dark-gray/80 {isDownloading
+                        ? 'border-blue-500/30 border-l-blue-400 group-hover:border-blue-500/50'
                         : isFailed
-                          ? 'border-red-500/30 border-l-red-400'
+                          ? 'border-red-500/30 border-l-red-400 group-hover:border-red-500/50'
                           : isLoading
-                            ? 'border-exo-yellow/30 border-l-yellow-400'
+                            ? 'border-exo-yellow/30 border-l-yellow-400 group-hover:border-exo-yellow/50'
                             : isReady
-                              ? 'border-green-500/30 border-l-green-400'
-                              : 'border-teal-500/30 border-l-teal-400'} p-3"
+                              ? 'border-green-500/30 border-l-green-400 group-hover:border-green-500/50'
+                              : 'border-teal-500/30 border-l-teal-400 group-hover:border-teal-500/50'} p-3"
                     >
                       <div class="flex justify-between items-start mb-2 pl-2">
                         <div class="flex items-center gap-2">
@@ -2842,10 +4423,25 @@
                         >
                           {getInstanceModelId(instance)}
                         </div>
-                        <div class="text-white/60 text-xs font-mono">
-                          Strategy: <span class="text-white/80"
-                            >{instanceInfo.sharding} ({instanceInfo.instanceType})</span
+                        <div
+                          class="flex items-center gap-2 text-white/60 text-xs font-mono"
+                        >
+                          <span
+                            >{instanceInfo.sharding} &middot; {instanceInfo.instanceType}</span
+                          >
+                          <span
+                            class="px-1.5 py-0.5 text-[10px] tracking-wider uppercase rounded transition-all duration-300 {isDownloading
+                              ? 'bg-blue-500/15 text-blue-400'
+                              : isFailed
+                                ? 'bg-red-500/15 text-red-400'
+                                : isLoading
+                                  ? 'bg-yellow-500/15 text-yellow-400'
+                                  : isReady
+                                    ? 'bg-green-500/15 text-green-400'
+                                    : 'bg-teal-500/15 text-teal-400'}"
                           >
+                            {statusText}
+                          </span>
                         </div>
                         {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
                           <a
@@ -3089,18 +4685,81 @@
                               {/each}
                             </div>
                           {/if}
-                          <div
-                            class="text-xs text-blue-400 font-mono tracking-wider mt-1"
-                          >
-                            DOWNLOADING
+                          <div class="mt-2 space-y-1">
+                            <div
+                              class="text-xs text-blue-400 font-mono tracking-wider"
+                            >
+                              DOWNLOADING
+                            </div>
+                            <p
+                              class="text-[11px] text-white/50 leading-relaxed"
+                            >
+                              Downloading model files. Model runs on your
+                              devices so needs to be downloaded before you can
+                              chat.
+                            </p>
                           </div>
                         {:else}
-                          <div
-                            class="text-xs {getStatusColor(
-                              downloadInfo.statusText,
-                            )} font-mono tracking-wider mt-1"
-                          >
-                            {downloadInfo.statusText}
+                          <div class="mt-1 space-y-1">
+                            <div
+                              class="text-xs {getStatusColor(
+                                downloadInfo.statusText,
+                              )} font-mono tracking-wider"
+                            >
+                              {downloadInfo.statusText}
+                            </div>
+                            {#if isLoading}
+                              {@const loadStatus =
+                                deriveInstanceStatus(instance)}
+                              {#if loadStatus.totalLayers && loadStatus.totalLayers > 0}
+                                <div class="mt-1 space-y-1">
+                                  <div
+                                    class="flex justify-between text-xs font-mono"
+                                  >
+                                    <span class="text-yellow-400"
+                                      >{(
+                                        ((loadStatus.layersLoaded ?? 0) /
+                                          loadStatus.totalLayers) *
+                                        100
+                                      ).toFixed(0)}%</span
+                                    >
+                                    <span class="text-exo-light-gray"
+                                      >{loadStatus.layersLoaded ?? 0} / {loadStatus.totalLayers}
+                                      layers</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-yellow-500 to-yellow-400 transition-all duration-300"
+                                      style="width: {((loadStatus.layersLoaded ??
+                                        0) /
+                                        loadStatus.totalLayers) *
+                                        100}%"
+                                    ></div>
+                                  </div>
+                                </div>
+                              {:else}
+                                <p
+                                  class="text-[11px] text-white/50 leading-relaxed"
+                                >
+                                  Loading model into memory...
+                                </p>
+                              {/if}
+                            {:else if isWarmingUp}
+                              <p
+                                class="text-[11px] text-white/50 leading-relaxed"
+                              >
+                                Warming up...
+                              </p>
+                            {:else if isReady || isRunning}
+                              <p
+                                class="text-[11px] text-green-400/70 leading-relaxed"
+                              >
+                                Ready to chat! Type a message below.
+                              </p>
+                            {/if}
                           </div>
                           {#if downloadInfo.isFailed && downloadInfo.errorMessage}
                             <div
@@ -3126,7 +4785,7 @@
               <h3
                 class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
               >
-                Launch Instance
+                Load Model
               </h3>
               <div
                 class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
@@ -3191,172 +4850,202 @@
               </button>
             </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>
+            <!-- Advanced Options Toggle -->
+            <div class="flex-shrink-0 mb-4">
+              <button
+                type="button"
+                onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
+                class="flex items-center gap-2 text-xs text-white/50 hover:text-white/70 font-mono tracking-wider uppercase transition-colors cursor-pointer py-1"
+                aria-expanded={showAdvancedOptions}
+              >
+                <svg
+                  class="w-3 h-3 transition-transform duration-200 {showAdvancedOptions
+                    ? 'rotate-90'
+                    : ''}"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                  stroke-width="2"
+                >
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    d="M9 5l7 7-7 7"
+                  />
+                </svg>
+                Advanced Options
+              </button>
 
-              <!-- 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>
+              {#if showAdvancedOptions}
+                <div class="mt-3 space-y-3 pl-1" in:fade={{ duration: 150 }}>
+                  <!-- Sharding Strategy -->
+                  <div>
+                    <div class="text-xs text-white/50 font-mono mb-2">
+                      Sharding Strategy:
+                    </div>
+                    <div class="flex gap-2">
+                      <button
+                        onclick={() => {
+                          selectedSharding = "Pipeline";
+                          saveLaunchDefaults();
+                        }}
+                        class="flex items-center gap-2 py-1.5 px-3 text-xs 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-3 h-3 rounded-full border-2 flex items-center justify-center {selectedSharding ===
+                          'Pipeline'
+                            ? 'border-exo-yellow'
+                            : 'border-exo-medium-gray'}"
+                        >
+                          {#if selectedSharding === "Pipeline"}
+                            <span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
+                            ></span>
+                          {/if}
+                        </span>
+                        Pipeline
+                      </button>
+                      <button
+                        onclick={() => {
+                          selectedSharding = "Tensor";
+                          saveLaunchDefaults();
+                        }}
+                        class="flex items-center gap-2 py-1.5 px-3 text-xs 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-3 h-3 rounded-full border-2 flex items-center justify-center {selectedSharding ===
+                          'Tensor'
+                            ? 'border-exo-yellow'
+                            : 'border-exo-medium-gray'}"
+                        >
+                          {#if selectedSharding === "Tensor"}
+                            <span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
+                            ></span>
+                          {/if}
+                        </span>
+                        Tensor
+                      </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}
+                  <!-- Interconnect -->
+                  <div>
+                    <div class="text-xs text-white/50 font-mono mb-2">
+                      Interconnect:
+                    </div>
+                    <div class="flex gap-2">
+                      <button
+                        onclick={() => {
+                          selectedInstanceType = "MlxRing";
+                          saveLaunchDefaults();
+                        }}
+                        class="flex items-center gap-2 py-1.5 px-3 text-xs 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-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
+                          'MlxRing'
+                            ? 'border-exo-yellow'
+                            : 'border-exo-medium-gray'}"
+                        >
+                          {#if selectedInstanceType === "MlxRing"}
+                            <span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
+                            ></span>
+                          {/if}
+                        </span>
+                        TCP/IP
+                      </button>
+                      <button
+                        onclick={() => {
+                          selectedInstanceType = "MlxJaccl";
+                          saveLaunchDefaults();
+                        }}
+                        class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
+                        'MlxJaccl'
+                          ? '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-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
+                          'MlxJaccl'
+                            ? 'border-exo-yellow'
+                            : 'border-exo-medium-gray'}"
+                        >
+                          {#if selectedInstanceType === "MlxJaccl"}
+                            <span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
+                            ></span>
+                          {/if}
+                        </span>
+                        RDMA (Fast)
+                      </button>
+                    </div>
+                  </div>
+
+                  <!-- Minimum Devices -->
+                  <div>
+                    <div class="text-xs text-white/50 font-mono mb-2">
+                      Minimum Devices:
+                    </div>
+                    <!-- Discrete slider track with drag support -->
+                    <!-- svelte-ignore a11y_no_static_element_interactions -->
                     <div
-                      class="absolute flex flex-col items-center pointer-events-none"
-                      style="left: {position}%; top: 0; transform: translateX(-50%);"
+                      bind:this={sliderTrackElement}
+                      class="relative h-16 cursor-pointer select-none px-2 pr-6"
+                      onmousedown={handleSliderMouseDown}
+                      ontouchstart={handleSliderTouchStart}
                     >
-                      <!-- 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
-                      >
+                      <!-- Track background -->
+                      <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 device 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%);"
+                        >
+                          <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>
+                          <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>
-                  {/each}
+                  </div>
                 </div>
-              </div>
+              {/if}
             </div>
 
             <!-- Selected Model Preview -->
@@ -3441,6 +5130,9 @@
           <div
             class="flex-1 overflow-y-auto px-8 py-6"
             bind:this={chatScrollRef}
+            role="log"
+            aria-live="polite"
+            aria-label="Chat messages"
           >
             <div class="max-w-7xl mx-auto">
               <ChatMessages scrollParent={chatScrollRef} />
@@ -3466,6 +5158,7 @@
           <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 }}
+            aria-label="Cluster topology"
           >
             <!-- Topology Section - clickable to go back to main view -->
             <button
@@ -3528,10 +5221,9 @@
                     {@const statusText = downloadInfo.statusText}
                     {@const isDownloading = downloadInfo.isDownloading}
                     {@const isFailed = statusText === "FAILED"}
-                    {@const isLoading =
-                      statusText === "LOADING" ||
-                      statusText === "WARMING UP" ||
-                      statusText === "WAITING"}
+                    {@const isLoading = statusText === "LOADING"}
+                    {@const isWarmingUp =
+                      statusText === "WARMING UP" || statusText === "WAITING"}
                     {@const isReady =
                       statusText === "READY" || statusText === "LOADED"}
                     {@const isRunning = statusText === "RUNNING"}
@@ -3655,10 +5347,25 @@
                           >
                             {getInstanceModelId(instance)}
                           </div>
-                          <div class="text-white/60 text-xs font-mono">
-                            Strategy: <span class="text-white/80"
-                              >{instanceInfo.sharding} ({instanceInfo.instanceType})</span
+                          <div
+                            class="flex items-center gap-2 text-white/60 text-xs font-mono"
+                          >
+                            <span
+                              >{instanceInfo.sharding} &middot; {instanceInfo.instanceType}</span
+                            >
+                            <span
+                              class="px-1.5 py-0.5 text-[10px] tracking-wider uppercase rounded transition-all duration-300 {isDownloading
+                                ? 'bg-blue-500/15 text-blue-400'
+                                : isFailed
+                                  ? 'bg-red-500/15 text-red-400'
+                                  : isLoading
+                                    ? 'bg-yellow-500/15 text-yellow-400'
+                                    : isReady
+                                      ? 'bg-green-500/15 text-green-400'
+                                      : 'bg-teal-500/15 text-teal-400'}"
                             >
+                              {statusText}
+                            </span>
                           </div>
                           {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
                             <a
@@ -3898,9 +5605,8 @@
                                                   )}</span
                                                 >
                                                 <span
-                                                  >{formatSpeed(f.speed)} • ETA {formatEta(
-                                                    f.etaMs,
-                                                  )}</span
+                                                  >{formatSpeed(f.speed)} • ETA
+                                                  {formatEta(f.etaMs)}</span
                                                 >
                                               </div>
                                             </div>
@@ -3912,18 +5618,81 @@
                                 {/each}
                               </div>
                             {/if}
-                            <div
-                              class="text-xs text-blue-400 font-mono tracking-wider mt-1"
-                            >
-                              DOWNLOADING
+                            <div class="mt-2 space-y-1">
+                              <div
+                                class="text-xs text-blue-400 font-mono tracking-wider"
+                              >
+                                DOWNLOADING
+                              </div>
+                              <p
+                                class="text-[11px] text-white/50 leading-relaxed"
+                              >
+                                Downloading model files. Model runs on your
+                                devices so needs to be downloaded before you can
+                                chat.
+                              </p>
                             </div>
                           {:else}
-                            <div
-                              class="text-xs {getStatusColor(
-                                downloadInfo.statusText,
-                              )} font-mono tracking-wider mt-1"
-                            >
-                              {downloadInfo.statusText}
+                            <div class="mt-1 space-y-1">
+                              <div
+                                class="text-xs {getStatusColor(
+                                  downloadInfo.statusText,
+                                )} font-mono tracking-wider"
+                              >
+                                {downloadInfo.statusText}
+                              </div>
+                              {#if isLoading}
+                                {@const loadStatus =
+                                  deriveInstanceStatus(instance)}
+                                {#if loadStatus.totalLayers && loadStatus.totalLayers > 0}
+                                  <div class="mt-1 space-y-1">
+                                    <div
+                                      class="flex justify-between text-xs font-mono"
+                                    >
+                                      <span class="text-yellow-400"
+                                        >{(
+                                          ((loadStatus.layersLoaded ?? 0) /
+                                            loadStatus.totalLayers) *
+                                          100
+                                        ).toFixed(0)}%</span
+                                      >
+                                      <span class="text-exo-light-gray"
+                                        >{loadStatus.layersLoaded ?? 0} / {loadStatus.totalLayers}
+                                        layers</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-yellow-500 to-yellow-400 transition-all duration-300"
+                                        style="width: {((loadStatus.layersLoaded ??
+                                          0) /
+                                          loadStatus.totalLayers) *
+                                          100}%"
+                                      ></div>
+                                    </div>
+                                  </div>
+                                {:else}
+                                  <p
+                                    class="text-[11px] text-white/50 leading-relaxed"
+                                  >
+                                    Loading model into memory...
+                                  </p>
+                                {/if}
+                              {:else if isWarmingUp}
+                                <p
+                                  class="text-[11px] text-white/50 leading-relaxed"
+                                >
+                                  Warming up...
+                                </p>
+                              {:else if isReady || isRunning}
+                                <p
+                                  class="text-[11px] text-green-400/70 leading-relaxed"
+                                >
+                                  Ready to chat! Type a message below.
+                                </p>
+                              {/if}
                             </div>
                             {#if downloadInfo.isFailed && downloadInfo.errorMessage}
                               <div
@@ -3947,29 +5716,31 @@
   </main>
 </div>
 
-<ModelPickerModal
-  isOpen={isModelPickerOpen}
-  {models}
-  {selectedModelId}
-  favorites={favoritesSet}
-  {recentModelIds}
-  hasRecents={showRecentsTab}
-  existingModelIds={new Set(models.map((m) => m.id))}
-  canModelFit={(modelId) => {
-    const model = models.find((m) => m.id === modelId);
-    return model ? hasEnoughMemory(model) : false;
-  }}
-  getModelFitStatus={(modelId): ModelMemoryFitStatus => {
-    const model = models.find((m) => m.id === modelId);
-    return model ? getModelMemoryFitStatus(model) : "too_large";
-  }}
-  onSelect={handleModelPickerSelect}
-  onClose={() => (isModelPickerOpen = false)}
-  onToggleFavorite={toggleFavorite}
-  onAddModel={addModelFromPicker}
-  onDeleteModel={deleteCustomModel}
-  totalMemoryGB={clusterMemory().total / (1024 * 1024 * 1024)}
-  usedMemoryGB={clusterMemory().used / (1024 * 1024 * 1024)}
-  {downloadsData}
-  topologyNodes={data?.nodes}
-/>
+{#if !showOnboarding}
+  <ModelPickerModal
+    isOpen={isModelPickerOpen}
+    {models}
+    {selectedModelId}
+    favorites={favoritesSet}
+    {recentModelIds}
+    hasRecents={showRecentsTab}
+    existingModelIds={new Set(models.map((m) => m.id))}
+    canModelFit={(modelId) => {
+      const model = models.find((m) => m.id === modelId);
+      return model ? hasEnoughMemory(model) : false;
+    }}
+    getModelFitStatus={(modelId): ModelMemoryFitStatus => {
+      const model = models.find((m) => m.id === modelId);
+      return model ? getModelMemoryFitStatus(model) : "too_large";
+    }}
+    onSelect={handleModelPickerSelect}
+    onClose={() => (isModelPickerOpen = false)}
+    onToggleFavorite={toggleFavorite}
+    onAddModel={addModelFromPicker}
+    onDeleteModel={deleteCustomModel}
+    totalMemoryGB={clusterMemory().total / (1024 * 1024 * 1024)}
+    usedMemoryGB={clusterMemory().used / (1024 * 1024 * 1024)}
+    {downloadsData}
+    topologyNodes={data?.nodes}
+  />
+{/if}
diff --git a/packaging/dmg/background.png b/packaging/dmg/background.png
new file mode 100644
index 00000000..5e56d8ab
Binary files /dev/null and b/packaging/dmg/background.png differ
diff --git a/packaging/dmg/create-dmg.sh b/packaging/dmg/create-dmg.sh
new file mode 100755
index 00000000..cd68b2a7
--- /dev/null
+++ b/packaging/dmg/create-dmg.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+# create-dmg.sh — Build a polished macOS DMG installer for EXO
+#
+# Usage:
+#   ./packaging/dmg/create-dmg.sh <app-path> <output-dmg> [volume-name]
+#
+# Example:
+#   ./packaging/dmg/create-dmg.sh output/EXO.app EXO-1.0.0.dmg "EXO"
+#
+# Creates a DMG with:
+#   - Custom background image with drag-to-Applications arrow
+#   - App icon on left, Applications alias on right
+#   - Proper window size and icon positioning
+set -euo pipefail
+
+APP_PATH="${1:?Usage: create-dmg.sh <app-path> <output-dmg> [volume-name]}"
+OUTPUT_DMG="${2:?Usage: create-dmg.sh <app-path> <output-dmg> [volume-name]}"
+VOLUME_NAME="${3:-EXO}"
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+BACKGROUND_SCRIPT="${SCRIPT_DIR}/generate-background.py"
+TEMP_DIR="$(mktemp -d)"
+DMG_STAGING="${TEMP_DIR}/dmg-root"
+TEMP_DMG="${TEMP_DIR}/temp.dmg"
+BACKGROUND_PNG="${TEMP_DIR}/background.png"
+
+cleanup() { rm -rf "$TEMP_DIR"; }
+trap cleanup EXIT
+
+echo "==> Creating DMG installer for ${VOLUME_NAME}"
+
+# ── Step 1: Generate background image ────────────────────────────────────────
+if command -v python3 &>/dev/null; then
+  python3 "$BACKGROUND_SCRIPT" "$BACKGROUND_PNG"
+  echo "    Background image generated"
+else
+  echo "    Warning: python3 not found, skipping custom background"
+  BACKGROUND_PNG=""
+fi
+
+# ── Step 2: Prepare staging directory ─────────────────────────────────────────
+mkdir -p "$DMG_STAGING"
+cp -R "$APP_PATH" "$DMG_STAGING/"
+ln -s /Applications "$DMG_STAGING/Applications"
+
+# ── Step 3: Create writable DMG ──────────────────────────────────────────────
+# Calculate required size (app size + 20MB headroom)
+APP_SIZE_KB=$(du -sk "$APP_PATH" | cut -f1)
+DMG_SIZE_KB=$((APP_SIZE_KB + 20480))
+
+hdiutil create \
+  -volname "$VOLUME_NAME" \
+  -size "${DMG_SIZE_KB}k" \
+  -fs HFS+ \
+  -layout SPUD \
+  "$TEMP_DMG"
+
+# ── Step 4: Mount and configure ──────────────────────────────────────────────
+MOUNT_DIR=$(hdiutil attach "$TEMP_DMG" -readwrite -noverify | awk -F'\t' '/Apple_HFS/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $NF); print $NF}')
+echo "    Mounted at: $MOUNT_DIR"
+
+# Copy contents
+cp -R "$DMG_STAGING/"* "$MOUNT_DIR/"
+
+# Add background image
+if [[ -n $BACKGROUND_PNG && -f $BACKGROUND_PNG ]]; then
+  mkdir -p "$MOUNT_DIR/.background"
+  cp "$BACKGROUND_PNG" "$MOUNT_DIR/.background/background.png"
+fi
+
+# ── Step 5: Configure window appearance via AppleScript ──────────────────────
+# Window: 800×400, app icon on left, Applications on right (matches Ollama layout)
+# Background image is 1600×740 (2× retina for 800×400 logical window).
+APP_NAME="$(basename "$APP_PATH")"
+
+osascript <<APPLESCRIPT
+tell application "Finder"
+    tell disk "$VOLUME_NAME"
+        open
+        set current view of container window to icon view
+        set toolbar visible of container window to false
+        set statusbar visible of container window to false
+        set bounds of container window to {200, 120, 1000, 520}
+        set opts to icon view options of container window
+        set icon size of opts to 128
+        set text size of opts to 12
+        set arrangement of opts to not arranged
+        if exists file ".background:background.png" then
+            set background picture of opts to file ".background:background.png"
+        end if
+        set position of item "$APP_NAME" of container window to {200, 190}
+        set position of item "Applications" of container window to {600, 190}
+        close
+        open
+        update without registering applications
+        delay 1
+        close
+    end tell
+end tell
+APPLESCRIPT
+
+echo "    Window layout configured"
+
+# Ensure Finder updates are flushed
+sync
+
+# ── Step 6: Finalise ─────────────────────────────────────────────────────────
+hdiutil detach "$MOUNT_DIR" -quiet
+hdiutil convert "$TEMP_DMG" -format UDZO -imagekey zlib-level=9 -o "$OUTPUT_DMG"
+
+echo "==> DMG created: $OUTPUT_DMG"
+echo "    Size: $(du -h "$OUTPUT_DMG" | cut -f1)"
diff --git a/packaging/dmg/generate-background.py b/packaging/dmg/generate-background.py
new file mode 100644
index 00000000..ac3dc649
--- /dev/null
+++ b/packaging/dmg/generate-background.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""Generate the DMG background image with a centered drag-to-Applications arrow.
+
+The output is a 1600×740 retina PNG (2× for 800×400 logical window).
+Icons are positioned at (200, 190) and (600, 190) in logical coordinates;
+the arrow is drawn centered between them.
+
+Usage:
+    python3 generate-background.py [output.png]
+
+If no output path is given, overwrites the bundled background.png in-place.
+"""
+
+from __future__ import annotations
+
+import math
+import sys
+from pathlib import Path
+
+from PIL import Image, ImageDraw
+
+# Retina dimensions (2× logical 800×400)
+WIDTH = 1600
+HEIGHT = 740
+
+# Icon positions in logical coords → retina coords
+# App icon at (200, 190), Applications at (600, 190)
+APP_X = 200 * 2  # 400
+APPS_X = 600 * 2  # 1200
+ICON_Y = 190 * 2  # 380
+
+# Arrow drawn between icons, slightly above icon center
+ARROW_START_X = APP_X + 160  # past the icon
+ARROW_END_X = APPS_X - 160  # before the Applications icon
+ARROW_Y = ICON_Y  # same height as icons
+ARROW_RISE = 120  # upward arc height
+
+
+def draw_arrow(draw: ImageDraw.ImageDraw) -> None:
+    """Draw a hand-drawn-style curved arrow from app icon toward Applications."""
+    color = (30, 30, 30)
+    line_width = 8
+
+    # Compute bezier curve points for a gentle upward arc
+    points: list[tuple[float, float]] = []
+    steps = 80
+    for i in range(steps + 1):
+        t = i / steps
+        # Quadratic bezier: start → control → end
+        cx = (ARROW_START_X + ARROW_END_X) / 2
+        cy = ARROW_Y - ARROW_RISE
+        x = (1 - t) ** 2 * ARROW_START_X + 2 * (1 - t) * t * cx + t**2 * ARROW_END_X
+        y = (1 - t) ** 2 * ARROW_Y + 2 * (1 - t) * t * cy + t**2 * ARROW_Y
+        points.append((x, y))
+
+    # Draw the curve as connected line segments
+    for i in range(len(points) - 1):
+        draw.line([points[i], points[i + 1]], fill=color, width=line_width)
+
+    # Arrowhead at the end
+    end_x, end_y = points[-1]
+    # Direction from second-to-last to last point
+    prev_x, prev_y = points[-3]
+    angle = math.atan2(end_y - prev_y, end_x - prev_x)
+    head_len = 36
+    head_angle = math.radians(25)
+
+    left_x = end_x - head_len * math.cos(angle - head_angle)
+    left_y = end_y - head_len * math.sin(angle - head_angle)
+    right_x = end_x - head_len * math.cos(angle + head_angle)
+    right_y = end_y - head_len * math.sin(angle + head_angle)
+
+    draw.polygon(
+        [(end_x, end_y), (left_x, left_y), (right_x, right_y)],
+        fill=color,
+    )
+
+
+def generate_background(output_path: str) -> None:
+    """Generate a white DMG background with a centered arrow."""
+    img = Image.new("RGBA", (WIDTH, HEIGHT), (255, 255, 255, 255))
+    draw = ImageDraw.Draw(img)
+    draw_arrow(draw)
+    img.save(output_path, "PNG")
+
+
+if __name__ == "__main__":
+    default_output = str(Path(__file__).parent / "background.png")
+    out = sys.argv[1] if len(sys.argv) >= 2 else default_output
+    generate_background(out)
+    print(f"Background image written to {out}")
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index 15d1e142..5ecc1575 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -51,6 +51,7 @@ from exo.master.placement import place_instance as get_instance_placements
 from exo.shared.apply import apply
 from exo.shared.constants import (
     DASHBOARD_DIR,
+    EXO_CACHE_HOME,
     EXO_EVENT_LOG_DIR,
     EXO_IMAGE_CACHE_DIR,
     EXO_MAX_CHUNK_SIZE,
@@ -175,6 +176,7 @@ from exo.utils.channels import Receiver, Sender, channel
 from exo.utils.event_buffer import OrderedBuffer
 
 _API_EVENT_LOG_DIR = EXO_EVENT_LOG_DIR / "api"
+ONBOARDING_COMPLETE_FILE = EXO_CACHE_HOME / "onboarding_complete"
 
 
 def _format_to_content_type(image_format: Literal["png", "jpeg", "webp"] | None) -> str:
@@ -345,6 +347,8 @@ class API:
         self.app.get("/v1/traces/{task_id}")(self.get_trace)
         self.app.get("/v1/traces/{task_id}/stats")(self.get_trace_stats)
         self.app.get("/v1/traces/{task_id}/raw")(self.get_trace_raw)
+        self.app.get("/onboarding")(self.get_onboarding)
+        self.app.post("/onboarding")(self.complete_onboarding)
 
     async def place_instance(self, payload: PlaceInstanceParams):
         command = PlaceInstance(
@@ -1814,3 +1818,11 @@ class API:
             media_type="application/json",
             filename=f"trace_{task_id}.json",
         )
+
+    async def get_onboarding(self) -> JSONResponse:
+        return JSONResponse({"completed": ONBOARDING_COMPLETE_FILE.exists()})
+
+    async def complete_onboarding(self) -> JSONResponse:
+        ONBOARDING_COMPLETE_FILE.parent.mkdir(parents=True, exist_ok=True)
+        ONBOARDING_COMPLETE_FILE.write_text("true")
+        return JSONResponse({"completed": True})
diff --git a/src/exo/shared/types/worker/runners.py b/src/exo/shared/types/worker/runners.py
index 7746949d..1ac68947 100644
--- a/src/exo/shared/types/worker/runners.py
+++ b/src/exo/shared/types/worker/runners.py
@@ -34,7 +34,8 @@ class RunnerConnected(BaseRunnerStatus):
 
 
 class RunnerLoading(BaseRunnerStatus):
-    pass
+    layers_loaded: int = 0
+    total_layers: int = 0
 
 
 class RunnerLoaded(BaseRunnerStatus):
diff --git a/src/exo/utils/banner.py b/src/exo/utils/banner.py
index ffdb5458..2742832e 100644
--- a/src/exo/utils/banner.py
+++ b/src/exo/utils/banner.py
@@ -1,8 +1,27 @@
+import logging
+import os
 import sys
+import webbrowser
+
+from exo.shared.constants import EXO_CONFIG_HOME
+
+logger = logging.getLogger(__name__)
+
+_FIRST_RUN_MARKER = EXO_CONFIG_HOME / ".dashboard_opened"
+
+
+def _is_first_run() -> bool:
+    return not _FIRST_RUN_MARKER.exists()
+
+
+def _mark_first_run_done() -> None:
+    _FIRST_RUN_MARKER.parent.mkdir(parents=True, exist_ok=True)
+    _FIRST_RUN_MARKER.touch()
 
 
 def print_startup_banner(port: int) -> None:
     dashboard_url = f"http://localhost:{port}"
+    first_run = _is_first_run()
     banner = f"""
 ╔═══════════════════════════════════════════════════════════════════════╗
 ║                                                                       ║
@@ -30,3 +49,14 @@ def print_startup_banner(port: int) -> None:
 """
 
     print(banner, file=sys.stderr)
+
+    if first_run:
+        # Skip browser open when running inside the native macOS app —
+        # FirstLaunchPopout.swift handles the auto-open with a countdown.
+        if not os.environ.get("EXO_RUNTIME_DIR"):
+            try:
+                webbrowser.open(dashboard_url)
+                logger.info("First run detected — opening dashboard in browser")
+            except Exception:
+                logger.debug("Could not auto-open browser", exc_info=True)
+        _mark_first_run_done()
diff --git a/src/exo/worker/engines/mlx/auto_parallel.py b/src/exo/worker/engines/mlx/auto_parallel.py
index 693913c4..6a949e6b 100644
--- a/src/exo/worker/engines/mlx/auto_parallel.py
+++ b/src/exo/worker/engines/mlx/auto_parallel.py
@@ -47,6 +47,7 @@ if TYPE_CHECKING:
     from mlx_lm.models.cache import Cache
 
 TimeoutCallback = Callable[[], None]
+LayerLoadedCallback = Callable[[int, int], None]  # (layers_loaded, total_layers)
 
 
 def eval_with_timeout(
@@ -186,7 +187,7 @@ def set_pipeline_prefill(model: nn.Module, is_prefill: bool) -> None:
             layer.is_prefill = is_prefill
 
 
-def _inner_model(model: nn.Module) -> nn.Module:
+def get_inner_model(model: nn.Module) -> nn.Module:
     inner = getattr(model, "model", None)
     if isinstance(inner, nn.Module):
         return inner
@@ -204,7 +205,7 @@ def _inner_model(model: nn.Module) -> nn.Module:
     raise ValueError("Model must either have a 'model' or 'transformer' attribute")
 
 
-def _get_layers(inner_model_instance: nn.Module) -> list[_LayerCallable]:
+def get_layers(inner_model_instance: nn.Module) -> list[_LayerCallable]:
     # Handle both model.layers and model.h cases
     layers: list[_LayerCallable]
     if hasattr(inner_model_instance, "layers"):
@@ -221,6 +222,7 @@ def pipeline_auto_parallel(
     model: nn.Module,
     group: mx.distributed.Group,
     model_shard_meta: PipelineShardMetadata,
+    on_layer_loaded: LayerLoadedCallback | None,
 ) -> nn.Module:
     """
     Automatically parallelize a model across multiple devices.
@@ -230,16 +232,19 @@ def pipeline_auto_parallel(
     Returns:
     The parallelized model
     """
-    inner_model_instance: nn.Module = _inner_model(model)
+    inner_model_instance: nn.Module = get_inner_model(model)
 
-    layers = _get_layers(inner_model_instance)
+    layers = get_layers(inner_model_instance)
 
     start_layer, end_layer = model_shard_meta.start_layer, model_shard_meta.end_layer
     device_rank, world_size = model_shard_meta.device_rank, model_shard_meta.world_size
 
     layers = layers[start_layer:end_layer]
-    for layer in layers:
+    total = len(layers)
+    for i, layer in enumerate(layers):
         mx.eval(layer)  # type: ignore
+        if on_layer_loaded is not None:
+            on_layer_loaded(i, total)
 
     layers[0] = PipelineFirstLayer(layers[0], device_rank, group=group)
     layers[-1] = PipelineLastLayer(
@@ -351,8 +356,9 @@ def patch_tensor_model[T](model: T) -> T:
 def tensor_auto_parallel(
     model: nn.Module,
     group: mx.distributed.Group,
-    timeout_seconds: float = 60.0,
-    on_timeout: TimeoutCallback | None = None,
+    timeout_seconds: float,
+    on_timeout: TimeoutCallback | None,
+    on_layer_loaded: LayerLoadedCallback | None,
 ) -> nn.Module:
     all_to_sharded_linear = partial(
         shard_linear,
@@ -462,7 +468,7 @@ def tensor_auto_parallel(
         raise ValueError(f"Unsupported model type: {type(model)}")
 
     model = tensor_parallel_sharding_strategy.shard_model(
-        model, timeout_seconds, on_timeout
+        model, timeout_seconds, on_timeout, on_layer_loaded
     )
     return patch_tensor_model(model)
 
@@ -489,6 +495,7 @@ class TensorParallelShardingStrategy(ABC):
         model: nn.Module,
         timeout_seconds: float,
         on_timeout: TimeoutCallback | None,
+        on_layer_loaded: LayerLoadedCallback | None,
     ) -> nn.Module: ...
 
 
@@ -498,13 +505,13 @@ class LlamaShardingStrategy(TensorParallelShardingStrategy):
         model: nn.Module,
         timeout_seconds: float,
         on_timeout: TimeoutCallback | None,
+        on_layer_loaded: LayerLoadedCallback | None,
     ) -> nn.Module:
         model = cast(LlamaModel, model)
-        for layer in model.layers:
+        total = len(model.layers)
+        for i, layer in enumerate(model.layers):
             # Force load weights before sharding to avoid FAST_SYNCH deadlock
-            eval_with_timeout(
-                layer.parameters(), timeout_seconds / len(model.layers), on_timeout
-            )
+            eval_with_timeout(layer.parameters(), timeout_seconds / total, on_timeout)
             layer.self_attn.q_proj = self.all_to_sharded_linear(layer.self_attn.q_proj)
             layer.self_attn.k_proj = self.all_to_sharded_linear(layer.self_attn.k_proj)
             layer.self_attn.v_proj = self.all_to_sharded_linear(layer.self_attn.v_proj)
@@ -517,11 +524,13 @@ class LlamaShardingStrategy(TensorParallelShardingStrategy):
             layer.mlp.down_proj = self.sharded_to_all_linear(layer.mlp.down_proj)
             layer.mlp.up_proj = self.all_to_sharded_linear(layer.mlp.up_proj)
             mx.eval(layer)
+            if on_layer_loaded is not None:
+                on_layer_loaded(i, total)
         return model
 
 
 def _set_layers(model: nn.Module, layers: list[_LayerCallable]) -> None:
-    inner_model_instance = _inner_model(model)
+    inner_model_instance = get_inner_model(model)
     if hasattr(inner_model_instance, "layers"):
         inner_model_instance.layers = layers
 
@@ -552,13 +561,13 @@ class DeepSeekShardingStrategy(TensorParallelShardingStrategy):
         model: nn.Module,
         timeout_seconds: float,
         on_timeout: TimeoutCallback | None,
+        on_layer_loaded: LayerLoadedCallback | None,
     ) -> nn.Module:
         model = cast(DeepseekV3Model, model)
+        total = len(model.layers)
 
-        for layer in model.layers:
-            eval_with_timeout(
-                layer.parameters(), timeout_seconds / len(model.layers), on_timeout
-            )
+        for i, layer in enumerate(model.layers):
+            eval_with_timeout(layer.parameters(), timeout_seconds / total, on_timeout)
 
             # Shard the self attention
             if layer.self_attn.q_lora_rank is None:
@@ -609,6 +618,8 @@ class DeepSeekShardingStrategy(TensorParallelShardingStrategy):
                 layer.mlp.sharding_group = self.group
 
             mx.eval(layer)
+            if on_layer_loaded is not None:
+                on_layer_loaded(i, total)
 
         return model
 
@@ -635,13 +646,15 @@ class GLM4MoeLiteShardingStrategy(TensorParallelShardingStrategy):
         model: nn.Module,
         timeout_seconds: float,
         on_timeout: TimeoutCallback | None,
+        on_layer_loaded: LayerLoadedCallback | None,
     ) -> nn.Module:
         model = cast(GLM4MoeLiteModel, model)
-        for layer in model.layers:  # type: ignore
+        total = len(model.layers)  # type: ignore
+        for i, layer in enumerate(model.layers):  # type: ignore
             layer = cast(Glm4MoeLiteDecoderLayer, layer)
             eval_with_timeout(
                 layer.parameters(),
-                timeout_seconds / len(model.layers),  # type: ignore
+                timeout_seconds / total,
                 on_timeout,
             )
             if layer.self_attn.q_lora_rank is None:  # type: ignore
@@ -689,6 +702,8 @@ class GLM4MoeLiteShardingStrategy(TensorParallelShardingStrategy):
                 layer.mlp = ShardedMoE(layer.mlp)  # type: ignore
                 layer.mlp.sharding_group = self.group  # type: ignore
             mx.eval(layer)
+            if on_layer_loaded is not None:
+                on_layer_loaded(i, total)
 
         return model
 
@@ -777,12 +792,12 @@ class MiniMaxShardingStrategy(TensorParallelShardingStrategy):
         model: nn.Module,
         timeout_seconds: float,
         on_timeout: TimeoutCallback | None,
+        on_layer_loaded: LayerLoadedCallback | None,
     ) -> nn.Module:
         model = cast(MiniMaxModel, model)
-        for layer in model.layers:
-            eval_with_timeout(
-                layer.parameters(), timeout_seconds / len(model.layers), on_timeout
-            )
+        total = len(model.layers)
+        for i, layer in enumerate(model.layers):
+            eval_with_timeout(layer.parameters(), timeout_seconds / total, on_timeout)
             # Shard the self attention
             layer.self_attn.q_proj = self.all_to_sharded_linear(layer.self_attn.q_proj)
             layer.self_attn.k_proj = self.all_to_sharded_linear(layer.self_attn.k_proj)
@@ -807,6 +822,8 @@ class MiniMaxShardingStrategy(TensorParallelShardingStrategy):
             layer.block_sparse_moe = ShardedMoE(layer.block_sparse_moe)  # pyright: ignore[reportAttributeAccessIssue, reportArgumentType]
             layer.block_sparse_moe.sharding_group = self.group  # pyright: ignore[reportAttributeAccessIssue]
             mx.eval(layer)
+            if on_layer_loaded is not None:
+                on_layer_loaded(i, total)
         return model
 
 
@@ -816,12 +833,12 @@ class QwenShardingStrategy(TensorParallelShardingStrategy):
         model: nn.Module,
         timeout_seconds: float,
         on_timeout: TimeoutCallback | None,
+        on_layer_loaded: LayerLoadedCallback | None,
     ) -> nn.Module:
         model = cast(Qwen3MoeModel | Qwen3NextModel, model)
-        for layer in model.layers:
-            eval_with_timeout(
-                layer.parameters(), timeout_seconds / len(model.layers), on_timeout
-            )
+        total = len(model.layers)
+        for i, layer in enumerate(model.layers):
+            eval_with_timeout(layer.parameters(), timeout_seconds / total, on_timeout)
             # Shard the self attention
             if isinstance(layer, Qwen3DecoderLayer):
                 layer.self_attn.q_proj = self.all_to_sharded_linear(
@@ -930,6 +947,8 @@ class QwenShardingStrategy(TensorParallelShardingStrategy):
                 layer.mlp.up_proj = self.all_to_sharded_linear(layer.mlp.up_proj)
 
             mx.eval(layer)
+            if on_layer_loaded is not None:
+                on_layer_loaded(i, total)
         return model
 
 
@@ -939,12 +958,12 @@ class Glm4MoeShardingStrategy(TensorParallelShardingStrategy):
         model: nn.Module,
         timeout_seconds: float,
         on_timeout: TimeoutCallback | None,
+        on_layer_loaded: LayerLoadedCallback | None,
     ) -> nn.Module:
         model = cast(Glm4MoeModel, model)
-        for layer in model.layers:
-            eval_with_timeout(
-                layer.parameters(), timeout_seconds / len(model.layers), on_timeout
-            )
+        total = len(model.layers)
+        for i, layer in enumerate(model.layers):
+            eval_with_timeout(layer.parameters(), timeout_seconds / total, on_timeout)
 
             layer.self_attn.q_proj = self.all_to_sharded_linear(layer.self_attn.q_proj)
             layer.self_attn.k_proj = self.all_to_sharded_linear(layer.self_attn.k_proj)
@@ -976,6 +995,8 @@ class Glm4MoeShardingStrategy(TensorParallelShardingStrategy):
                 layer.mlp.up_proj = self.all_to_sharded_linear(layer.mlp.up_proj)
 
             mx.eval(layer)
+            if on_layer_loaded is not None:
+                on_layer_loaded(i, total)
         return model
 
 
@@ -985,13 +1006,13 @@ class GptOssShardingStrategy(TensorParallelShardingStrategy):
         model: nn.Module,
         timeout_seconds: float,
         on_timeout: TimeoutCallback | None,
+        on_layer_loaded: LayerLoadedCallback | None,
     ) -> nn.Module:
         model = cast(GptOssMoeModel, model)
+        total = len(model.layers)
 
-        for layer in model.layers:
-            eval_with_timeout(
-                layer.parameters(), timeout_seconds / len(model.layers), on_timeout
-            )
+        for i, layer in enumerate(model.layers):
+            eval_with_timeout(layer.parameters(), timeout_seconds / total, on_timeout)
             layer.self_attn.q_proj = self.all_to_sharded_linear(layer.self_attn.q_proj)
             layer.self_attn.k_proj = self.all_to_sharded_linear(layer.self_attn.k_proj)
             layer.self_attn.v_proj = self.all_to_sharded_linear(layer.self_attn.v_proj)
@@ -1017,6 +1038,8 @@ class GptOssShardingStrategy(TensorParallelShardingStrategy):
             layer.mlp = ShardedMoE(layer.mlp)  # type: ignore
             layer.mlp.sharding_group = self.group  # pyright: ignore[reportAttributeAccessIssue]
             mx.eval(layer)
+            if on_layer_loaded is not None:
+                on_layer_loaded(i, total)
         return model
 
 
@@ -1026,13 +1049,13 @@ class Step35ShardingStrategy(TensorParallelShardingStrategy):
         model: nn.Module,
         timeout_seconds: float,
         on_timeout: TimeoutCallback | None,
+        on_layer_loaded: LayerLoadedCallback | None,
     ) -> nn.Module:
         model = cast(Step35Model, model)
+        total = len(model.layers)
 
-        for layer in model.layers:
-            eval_with_timeout(
-                layer.parameters(), timeout_seconds / len(model.layers), on_timeout
-            )
+        for i, layer in enumerate(model.layers):
+            eval_with_timeout(layer.parameters(), timeout_seconds / total, on_timeout)
             layer.self_attn.q_proj = self.all_to_sharded_linear(layer.self_attn.q_proj)
             layer.self_attn.k_proj = self.all_to_sharded_linear(layer.self_attn.k_proj)
             layer.self_attn.v_proj = self.all_to_sharded_linear(layer.self_attn.v_proj)
@@ -1060,4 +1083,6 @@ class Step35ShardingStrategy(TensorParallelShardingStrategy):
                 self.sharded_to_all_linear_in_place(layer.mlp.switch_mlp.down_proj)
 
             mx.eval(layer)
+            if on_layer_loaded is not None:
+                on_layer_loaded(i, total)
         return model
diff --git a/src/exo/worker/engines/mlx/utils_mlx.py b/src/exo/worker/engines/mlx/utils_mlx.py
index 360a018b..48b902ff 100644
--- a/src/exo/worker/engines/mlx/utils_mlx.py
+++ b/src/exo/worker/engines/mlx/utils_mlx.py
@@ -55,8 +55,11 @@ from exo.shared.types.worker.shards import (
 )
 from exo.worker.engines.mlx import Model
 from exo.worker.engines.mlx.auto_parallel import (
+    LayerLoadedCallback,
     TimeoutCallback,
     eval_with_timeout,
+    get_inner_model,
+    get_layers,
     pipeline_auto_parallel,
     tensor_auto_parallel,
 )
@@ -171,13 +174,28 @@ def initialize_mlx(
 def load_mlx_items(
     bound_instance: BoundInstance,
     group: Group | None,
-    on_timeout: TimeoutCallback | None = None,
+    on_timeout: TimeoutCallback | None,
+    on_layer_loaded: LayerLoadedCallback | None,
 ) -> tuple[Model, TokenizerWrapper]:
     if group is None:
         logger.info(f"Single device used for {bound_instance.instance}")
         model_path = build_model_path(bound_instance.bound_shard.model_card.model_id)
         start_time = time.perf_counter()
-        model, _ = load_model(model_path, strict=True)
+        model, _ = load_model(model_path, lazy=True, strict=False)
+        # Eval layers one by one for progress reporting
+        try:
+            inner = get_inner_model(model)
+            layers = get_layers(inner)
+            total = len(layers)
+            for i, layer in enumerate(layers):
+                mx.eval(layer)  # type: ignore
+                if on_layer_loaded is not None:
+                    on_layer_loaded(i, total)
+        except ValueError as e:
+            logger.opt(exception=e).debug(
+                "Model architecture doesn't support layer-by-layer progress tracking"
+            )
+        mx.eval(model)
         end_time = time.perf_counter()
         logger.info(f"Time taken to load model: {(end_time - start_time):.2f}s")
         tokenizer = get_tokenizer(model_path, bound_instance.bound_shard)
@@ -186,7 +204,10 @@ def load_mlx_items(
         logger.info("Starting distributed init")
         start_time = time.perf_counter()
         model, tokenizer = shard_and_load(
-            bound_instance.bound_shard, group=group, on_timeout=on_timeout
+            bound_instance.bound_shard,
+            group=group,
+            on_timeout=on_timeout,
+            on_layer_loaded=on_layer_loaded,
         )
         end_time = time.perf_counter()
         logger.info(
@@ -201,7 +222,8 @@ def load_mlx_items(
 def shard_and_load(
     shard_metadata: ShardMetadata,
     group: Group,
-    on_timeout: TimeoutCallback | None = None,
+    on_timeout: TimeoutCallback | None,
+    on_layer_loaded: LayerLoadedCallback | None,
 ) -> tuple[nn.Module, TokenizerWrapper]:
     model_path = build_model_path(shard_metadata.model_card.model_id)
 
@@ -242,10 +264,14 @@ def shard_and_load(
     match shard_metadata:
         case TensorShardMetadata():
             logger.info(f"loading model from {model_path} with tensor parallelism")
-            model = tensor_auto_parallel(model, group, timeout_seconds, on_timeout)
+            model = tensor_auto_parallel(
+                model, group, timeout_seconds, on_timeout, on_layer_loaded
+            )
         case PipelineShardMetadata():
             logger.info(f"loading model from {model_path} with pipeline parallelism")
-            model = pipeline_auto_parallel(model, group, shard_metadata)
+            model = pipeline_auto_parallel(
+                model, group, shard_metadata, on_layer_loaded=on_layer_loaded
+            )
             eval_with_timeout(model.parameters(), timeout_seconds, on_timeout)
         case CfgShardMetadata():
             raise ValueError(
diff --git a/src/exo/worker/runner/llm_inference/runner.py b/src/exo/worker/runner/llm_inference/runner.py
index c4059b9e..4c762de2 100644
--- a/src/exo/worker/runner/llm_inference/runner.py
+++ b/src/exo/worker/runner/llm_inference/runner.py
@@ -150,7 +150,10 @@ def main(
                 case LoadModel() if (
                     isinstance(current_status, RunnerConnected) and group is not None
                 ) or (isinstance(current_status, RunnerIdle) and group is None):
-                    current_status = RunnerLoading()
+                    total_layers = shard_metadata.end_layer - shard_metadata.start_layer
+                    current_status = RunnerLoading(
+                        layers_loaded=0, total_layers=total_layers
+                    )
                     logger.info("runner loading")
                     event_sender.send(
                         RunnerStatusUpdated(
@@ -170,11 +173,26 @@ def main(
                         )
                         time.sleep(0.5)
 
+                    def on_layer_loaded(layers_loaded: int, total: int) -> None:
+                        nonlocal current_status
+                        current_status = RunnerLoading(
+                            layers_loaded=layers_loaded, total_layers=total
+                        )
+                        event_sender.send(
+                            RunnerStatusUpdated(
+                                runner_id=runner_id,
+                                runner_status=current_status,
+                            )
+                        )
+
                     assert (
                         ModelTask.TextGeneration in shard_metadata.model_card.tasks
                     ), f"Incorrect model task(s): {shard_metadata.model_card.tasks}"
                     inference_model, tokenizer = load_mlx_items(
-                        bound_instance, group, on_timeout=on_model_load_timeout
+                        bound_instance,
+                        group,
+                        on_timeout=on_model_load_timeout,
+                        on_layer_loaded=on_layer_loaded,
                     )
                     logger.info(
                         f"model has_tool_calling={tokenizer.has_tool_calling} using tokens {tokenizer.tool_call_start}, {tokenizer.tool_call_end}"
diff --git a/src/exo/worker/tests/unittests/test_mlx/conftest.py b/src/exo/worker/tests/unittests/test_mlx/conftest.py
index 2f9e1fd3..b1ed29cd 100644
--- a/src/exo/worker/tests/unittests/test_mlx/conftest.py
+++ b/src/exo/worker/tests/unittests/test_mlx/conftest.py
@@ -96,7 +96,9 @@ def run_gpt_oss_pipeline_device(
             n_layers=24,
         )
 
-        model, tokenizer = shard_and_load(shard_meta, group)
+        model, tokenizer = shard_and_load(
+            shard_meta, group, on_timeout=None, on_layer_loaded=None
+        )
         model = cast(Model, model)
 
         # Generate a prompt of exact token length
@@ -172,7 +174,9 @@ def run_gpt_oss_tensor_parallel_device(
             n_layers=24,
         )
 
-        model, tokenizer = shard_and_load(shard_meta, group)
+        model, tokenizer = shard_and_load(
+            shard_meta, group, on_timeout=None, on_layer_loaded=None
+        )
         model = cast(Model, model)
 
         base_text = "The quick brown fox jumps over the lazy dog. "
diff --git a/src/exo/worker/tests/unittests/test_runner/test_event_ordering.py b/src/exo/worker/tests/unittests/test_runner/test_event_ordering.py
index 6d964583..91b75c12 100644
--- a/src/exo/worker/tests/unittests/test_runner/test_event_ordering.py
+++ b/src/exo/worker/tests/unittests/test_runner/test_event_ordering.py
@@ -224,7 +224,10 @@ def test_events_processed_in_correct_order(patch_out_mlx: pytest.MonkeyPatch):
             ),
             RunnerStatusUpdated(runner_id=RUNNER_1_ID, runner_status=RunnerConnected()),
             TaskStatusUpdated(task_id=LOAD_TASK_ID, task_status=TaskStatus.Running),
-            RunnerStatusUpdated(runner_id=RUNNER_1_ID, runner_status=RunnerLoading()),
+            RunnerStatusUpdated(
+                runner_id=RUNNER_1_ID,
+                runner_status=RunnerLoading(layers_loaded=0, total_layers=32),
+            ),
             TaskAcknowledged(task_id=LOAD_TASK_ID),
             TaskStatusUpdated(task_id=LOAD_TASK_ID, task_status=TaskStatus.Complete),
             RunnerStatusUpdated(runner_id=RUNNER_1_ID, runner_status=RunnerLoaded()),

← a4c2aa2b fix: raise error when MlxJaccl requested without RDMA cycles  ·  back to Exo  ·  bench: fix KeyError on DownloadCompleted total field 8d94eab6 →