← 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
M app/EXO/EXO/ContentView.swiftM app/EXO/EXO/EXOApp.swiftM app/EXO/EXO/ExoProcessController.swiftA app/EXO/EXO/Views/FirstLaunchPopout.swiftA app/EXO/EXO/Views/SettingsView.swiftA app/EXO/EXO/Views/SettingsWindowController.swiftM dashboard/src/app.cssM dashboard/src/lib/components/ChatForm.svelteM dashboard/src/lib/components/ChatMessages.svelteM dashboard/src/lib/components/ChatSidebar.svelteA dashboard/src/lib/components/ConnectionBanner.svelteA dashboard/src/lib/components/DeviceIcon.svelteM dashboard/src/lib/components/HeaderNav.svelteM dashboard/src/lib/components/ModelCard.svelteM dashboard/src/lib/components/ModelPickerModal.svelteA dashboard/src/lib/components/ToastContainer.svelteM dashboard/src/lib/stores/app.svelte.tsA dashboard/src/lib/stores/toast.svelte.tsM dashboard/src/routes/+layout.svelteM dashboard/src/routes/+page.svelteA packaging/dmg/background.pngA packaging/dmg/create-dmg.shA packaging/dmg/generate-background.pyM src/exo/master/api.pyM src/exo/shared/types/worker/runners.pyM src/exo/utils/banner.pyM src/exo/worker/engines/mlx/auto_parallel.pyM src/exo/worker/engines/mlx/utils_mlx.pyM src/exo/worker/runner/llm_inference/runner.pyM src/exo/worker/tests/unittests/test_mlx/conftest.pyM src/exo/worker/tests/unittests/test_runner/test_event_ordering.py
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 · 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 — Reconnecting to backend…
+ </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)}% · {formatSpeed(progress.speed)}
+ · {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}
+
+ {: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}
+
+ {/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…
+ </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} · {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} · {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 →