← back to Exo
feat: better onboarding UX for new users (#1479)
490d2e46ba469834215f318a358b37deb5e0e9a5 · 2026-02-17 09:52:49 -0800 · Alex Cheema
## Summary
- **Auto-open dashboard** in browser on first launch (uses
`~/.exo/.dashboard_opened` marker)
- **Welcome overlay** with "Choose a Model" CTA button when no model
instance is running
- **Tutorial progress messages** during model download → loading → ready
lifecycle stages
- **Fix conversation sidebar** text contrast — bumped to white text,
added active state background
- **Simplify technical jargon** — sharding/instance type/min nodes
hidden behind collapsible "Advanced Options" toggle; strategy display
hidden behind debug mode
- **Polished DMG installer** with drag-to-Applications layout, custom
branded background, and AppleScript-configured window positioning
## Test plan
- [ ] Launch exo for the first time (delete `~/.exo/.dashboard_opened`
to simulate) — browser should auto-open
- [ ] Verify welcome overlay appears on topology when no model is loaded
- [ ] Launch a model and verify download/loading/ready messages appear
in instance cards
- [ ] Check conversation sidebar text is readable (white on dark, yellow
when active)
- [ ] Verify "Advanced Options" toggle hides/shows sharding controls
- [ ] Build DMG with `packaging/dmg/create-dmg.sh` and verify
drag-to-Applications layout
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Files touched
M .github/workflows/build-app.ymlM 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.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/utils/banner.py
Diff
commit 490d2e46ba469834215f318a358b37deb5e0e9a5
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date: Tue Feb 17 09:52:49 2026 -0800
feat: better onboarding UX for new users (#1479)
## Summary
- **Auto-open dashboard** in browser on first launch (uses
`~/.exo/.dashboard_opened` marker)
- **Welcome overlay** with "Choose a Model" CTA button when no model
instance is running
- **Tutorial progress messages** during model download → loading → ready
lifecycle stages
- **Fix conversation sidebar** text contrast — bumped to white text,
added active state background
- **Simplify technical jargon** — sharding/instance type/min nodes
hidden behind collapsible "Advanced Options" toggle; strategy display
hidden behind debug mode
- **Polished DMG installer** with drag-to-Applications layout, custom
branded background, and AppleScript-configured window positioning
## Test plan
- [ ] Launch exo for the first time (delete `~/.exo/.dashboard_opened`
to simulate) — browser should auto-open
- [ ] Verify welcome overlay appears on topology when no model is loaded
- [ ] Launch a model and verify download/loading/ready messages appear
in instance cards
- [ ] Check conversation sidebar text is readable (white on dark, yellow
when active)
- [ ] Verify "Advanced Options" toggle hides/shows sharding controls
- [ ] Build DMG with `packaging/dmg/create-dmg.sh` and verify
drag-to-Applications layout
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
---
.github/workflows/build-app.yml | 5 +-
app/EXO/EXO/ContentView.swift | 476 +--
app/EXO/EXO/EXOApp.swift | 16 +-
app/EXO/EXO/ExoProcessController.swift | 20 +
app/EXO/EXO/Views/FirstLaunchPopout.swift | 192 +
app/EXO/EXO/Views/SettingsView.swift | 478 +++
app/EXO/EXO/Views/SettingsWindowController.swift | 47 +
dashboard/src/app.css | 37 +
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/HeaderNav.svelte | 77 +-
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 | 25 +-
dashboard/src/lib/stores/toast.svelte.ts | 87 +
dashboard/src/routes/+layout.svelte | 4 +
dashboard/src/routes/+page.svelte | 4380 +++++++++++++-------
packaging/dmg/background.png | Bin 0 -> 7010 bytes
packaging/dmg/create-dmg.sh | 112 +
packaging/dmg/generate-background.py | 91 +
src/exo/utils/banner.py | 30 +
24 files changed, 4370 insertions(+), 1986 deletions(-)
diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml
index d20bfc12..861f4697 100644
--- a/.github/workflows/build-app.yml
+++ b/.github/workflows/build-app.yml
@@ -303,11 +303,8 @@ jobs:
SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
/usr/bin/codesign --deep --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" EXO.app
- mkdir -p dmg-root
- cp -R EXO.app dmg-root/
- ln -s /Applications dmg-root/Applications
DMG_NAME="EXO-${RELEASE_VERSION}.dmg"
- hdiutil create -volname "EXO" -srcfolder dmg-root -ov -format UDZO "$DMG_NAME"
+ bash "$GITHUB_WORKSPACE/packaging/dmg/create-dmg.sh" EXO.app "$DMG_NAME" "EXO"
/usr/bin/codesign --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$DMG_NAME"
if [[ -n "$APPLE_NOTARIZATION_USERNAME" ]]; then
diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift
index 688fba5b..ca09fe2f 100644
--- a/app/EXO/EXO/ContentView.swift
+++ b/app/EXO/EXO/ContentView.swift
@@ -15,18 +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 showAdvanced = false
- @State private var showDebugInfo = false
- @State private var bugReportInFlight = false
- @State private var bugReportMessage: String?
- @State private var uninstallInProgress = false
- @State private var pendingNamespace: String = ""
- @State private var pendingHFToken: String = ""
- @State private var pendingEnableImageModels = false
+ @State private var baseURLCopied = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -258,139 +252,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 {
@@ -445,207 +379,6 @@ struct ContentView: View {
}
}
- private var thunderboltStatusText: String {
- switch networkStatusService.status.thunderboltBridgeState {
- case .some(.disabled):
- return "Thunderbolt Bridge: Disabled"
- case .some(.deleted):
- return "Thunderbolt Bridge: Deleted"
- case .some(.enabled):
- return "Thunderbolt Bridge: Enabled"
- case nil:
- return "Thunderbolt Bridge: 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
- }
- }
-
- /// Shows TB bridge status for all nodes from exo cluster state
- 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 debugSection: some View {
- VStack(alignment: .leading, spacing: 4) {
- HoverButton(
- title: "Debug Info",
- tint: .primary,
- trailingSystemImage: showDebugInfo ? "chevron.up" : "chevron.down",
- small: true
- ) {
- showDebugInfo.toggle()
- }
- if showDebugInfo {
- VStack(alignment: .leading, spacing: 4) {
- Text("Version: \(buildTag)")
- .font(.caption2)
- .foregroundColor(.secondary)
- Text("Commit: \(buildCommit)")
- .font(.caption2)
- .foregroundColor(.secondary)
- Text(thunderboltStatusText)
- .font(.caption2)
- .foregroundColor(thunderboltStatusColor)
- clusterThunderboltBridgeView
- interfaceIpList
- rdmaStatusView
- sendBugReportButton
- .padding(.top, 6)
- }
- .padding(.leading, 8)
- .transition(.opacity)
- }
- }
- .animation(.easeInOut(duration: 0.25), value: showDebugInfo)
- }
-
- 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()
- }
- .padding(.vertical, 6)
- .padding(.horizontal, 8)
- .background(
- RoundedRectangle(cornerRadius: 6)
- .fill(Color.accentColor.opacity(0.12))
- )
- }
- .buttonStyle(.plain)
- .disabled(bugReportInFlight)
-
- if let message = bugReportMessage {
- Text(message)
- .font(.caption2)
- .foregroundColor(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- }
-
private var processToggleBinding: Binding<Bool> {
Binding(
get: {
@@ -686,101 +419,6 @@ struct ContentView: View {
)
}
- 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")
-
- // Style the Uninstall button as destructive
- if let uninstallButton = alert.buttons.first {
- uninstallButton.hasDestructiveAction = true
- }
-
- let response = alert.runModal()
- if response == .alertFirstButtonReturn {
- performUninstall()
- }
- }
-
- private func performUninstall() {
- uninstallInProgress = true
-
- // Stop EXO process first
- controller.cancelPendingLaunch()
- controller.stop()
- stateService.stopPolling()
-
- // Run the privileged uninstall on a background thread
- // Using .utility QoS to avoid priority inversion with NSAppleScript's subprocess
- DispatchQueue.global(qos: .utility).async {
- do {
- // Remove network setup daemon and components (requires admin privileges)
- try NetworkSetupHelper.uninstall()
-
- DispatchQueue.main.async {
- // Unregister from launch at login
- LaunchAtLoginHelper.disable()
-
- // Move app to trash
- self.moveAppToTrash()
-
- // Quit the app
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- NSApplication.shared.terminate(nil)
- }
- }
- } catch {
- DispatchQueue.main.async {
- self.showErrorAlert(message: error.localizedDescription)
- self.uninstallInProgress = false
- }
- }
- }
- }
-
- private func showErrorAlert(message: String) {
- let alert = NSAlert()
- alert.messageText = "Uninstall Failed"
- alert.informativeText = message
- alert.alertStyle = .critical
- alert.addButton(withTitle: "OK")
- alert.runModal()
- }
-
- 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
- // The important system components have already been cleaned up
- }
- }
-
private var buildTag: String {
Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
}
diff --git a/app/EXO/EXO/EXOApp.swift b/app/EXO/EXO/EXOApp.swift
index 3aff58c3..bba26e24 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() + 1.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..2350c118 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,11 @@ final class ExoProcessController: ObservableObject {
try child.run()
process = child
status = .running
+
+ // Show welcome popout if onboarding was never completed
+ if !UserDefaults.standard.bool(forKey: onboardingCompletedKey) {
+ isFirstLaunchReady = true
+ }
} catch {
process = nil
status = .failed(message: "Launch error")
@@ -164,6 +173,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..1a10b3ac
--- /dev/null
+++ b/app/EXO/EXO/Views/FirstLaunchPopout.swift
@@ -0,0 +1,192 @@
+import AppKit
+import SwiftUI
+
+/// A popover callout anchored to the menu bar icon on first 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 a few times.
+ showWithRetry(attemptsRemaining: 5)
+ }
+
+ private func showWithRetry(attemptsRemaining: Int) {
+ guard attemptsRemaining > 0 else { return }
+
+ guard let button = Self.findStatusItemButton() else {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [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: 30,
+ 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 30s then dismiss
+ countdownTask = Task {
+ try? await Task.sleep(nanoseconds: 30_000_000_000)
+ if !Task.isCancelled {
+ openDashboard()
+ onComplete?()
+ dismiss()
+ }
+ }
+ }
+
+ func dismiss() {
+ countdownTask?.cancel()
+ countdownTask = nil
+ UserDefaults.standard.set(true, forKey: "EXOOnboardingCompleted")
+ 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))
+ if className.contains("NSStatusBarWindow") {
+ // Try to find the actual status bar button in the view hierarchy
+ if let content = window.contentView {
+ if let button = findButton(in: content) {
+ return button
+ }
+ 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") {
+ 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("Welcome to EXO!")
+ .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("Auto-opens in \(countdown)s")
+ .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..3e951163 100644
--- a/dashboard/src/app.css
+++ b/dashboard/src/app.css
@@ -202,6 +202,15 @@
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));
+}
+
.graph-link-active {
stroke: oklch(0.85 0.18 85 / 0.8);
stroke-width: 2px;
@@ -320,3 +329,31 @@ input:focus, textarea:focus {
transform: translate(400px, 400px);
}
}
+
+/* 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;
+ }
+ *,
+ *::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 2eb7aa40..1bc554e8 100644
--- a/dashboard/src/lib/components/ChatForm.svelte
+++ b/dashboard/src/lib/components/ChatForm.svelte
@@ -28,6 +28,7 @@
showModelSelector?: boolean;
modelTasks?: Record<string, string[]>;
modelCapabilities?: Record<string, string[]>;
+ onSend?: () => void;
}
let {
@@ -38,6 +39,7 @@
showModelSelector = false,
modelTasks = {},
modelCapabilities = {},
+ onSend,
}: Props = $props();
let message = $state("");
@@ -305,6 +307,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 ba5322a7..7c076459 100644
--- a/dashboard/src/lib/components/ChatMessages.svelte
+++ b/dashboard/src/lib/components/ChatMessages.svelte
@@ -802,8 +802,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}
@@ -818,6 +818,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/HeaderNav.svelte b/dashboard/src/lib/components/HeaderNav.svelte
index 5bdcf1ba..d78bc809 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) {
@@ -35,11 +39,15 @@
onclick={handleToggleSidebar}
class="p-2 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
title={sidebarVisible ? "Hide sidebar" : "Show sidebar"}
+ 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"
@@ -80,8 +88,9 @@
</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 ebb2d0df..5a5afc78 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -587,6 +587,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;
@@ -1290,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);
}
}
@@ -1817,7 +1835,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);
@@ -2255,7 +2273,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.",
);
}
@@ -3144,6 +3162,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 5f7dcf04..20f196ee 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -36,6 +36,7 @@
createConversation,
setSelectedChatModel,
selectedChatModel,
+ sendMessage,
debugMode,
toggleDebugMode,
topologyOnlyMode,
@@ -52,9 +53,11 @@
type PlacementPreview,
type MetaInstanceData,
} from "$lib/stores/app.svelte";
+ import { addToast } from "$lib/stores/toast.svelte";
import HeaderNav from "$lib/components/HeaderNav.svelte";
- import { fade, fly } from "svelte/transition";
- import { cubicInOut } from "svelte/easing";
+ 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());
@@ -140,6 +143,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;
@@ -230,6 +253,171 @@
let mounted = $state(false);
+ // ── Onboarding wizard state ──
+ const ONBOARDING_COMPLETE_KEY = "exo-onboarding-complete";
+ let onboardingStep = $state(0); // 0 = not in onboarding, 1-7 = wizard steps
+ let onboardingModelId = $state<string | null>(null); // model selected during onboarding
+ const showOnboarding = $derived(onboardingStep > 0);
+
+ // ── Step 2 animation state: "Add more devices, run bigger models" ──
+ let deviceAnimPhase = $state(0); // 0=waiting, 1=macbook, 2=studio joins, 3=connection+mid unlock, 4=big unlock
+ let showContinueStep2 = $state(false);
+ const studioX = tweened(540, { duration: 700, easing: cubicOut });
+ const studioOpacity = tweened(0, { duration: 700, easing: cubicOut });
+
+ $effect(() => {
+ if (onboardingStep === 2) {
+ deviceAnimPhase = 0;
+ showContinueStep2 = false;
+ studioX.set(540, { duration: 0 });
+ studioOpacity.set(0, { duration: 0 });
+
+ const t1 = setTimeout(() => {
+ deviceAnimPhase = 1;
+ }, 100);
+ const t2 = setTimeout(() => {
+ deviceAnimPhase = 2;
+ studioX.set(340);
+ studioOpacity.set(1);
+ }, 900);
+ const t3 = setTimeout(() => {
+ deviceAnimPhase = 3;
+ }, 1700);
+ const t4 = setTimeout(() => {
+ deviceAnimPhase = 4;
+ }, 2500);
+ const t5 = setTimeout(() => {
+ showContinueStep2 = true;
+ }, 3500);
+
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ clearTimeout(t3);
+ clearTimeout(t4);
+ clearTimeout(t5);
+ };
+ }
+ });
+
+ // Recommended models for onboarding (sorted by fit, then size desc, limited to 6)
+ const onboardingModels = $derived.by(() => {
+ if (models.length === 0) return [];
+ return [...models]
+ .filter((m) => getModelMemoryFitStatus(m) !== "too_large")
+ .sort((a, b) => {
+ const aFit = hasEnoughMemory(a) ? 0 : 1;
+ const bFit = hasEnoughMemory(b) ? 0 : 1;
+ if (aFit !== bFit) return aFit - bFit;
+ return getModelSizeGB(b) - getModelSizeGB(a);
+ })
+ .slice(0, 6);
+ });
+
+ // Track onboarding instance status for auto-advancing steps.
+ // Handles cached models: if no download is needed, skip step 5 entirely.
+ $effect(() => {
+ if (onboardingStep === 5 && instanceCount > 0) {
+ let anyDownloading = false;
+ let anyReady = false;
+ for (const [id, inst] of Object.entries(instanceData)) {
+ const status = getInstanceDownloadStatus(id, inst);
+ if (status.isDownloading) {
+ anyDownloading = true;
+ }
+ if (
+ status.statusText === "READY" ||
+ status.statusText === "LOADED" ||
+ status.statusText === "RUNNING"
+ ) {
+ anyReady = true;
+ }
+ }
+ // Model already cached & ready — skip download AND loading steps
+ if (anyReady) {
+ onboardingStep = 7;
+ } else if (!anyDownloading) {
+ // Download finished (or was never needed) but not ready yet
+ onboardingStep = 6;
+ }
+ }
+ });
+
+ $effect(() => {
+ if (onboardingStep === 6 && instanceCount > 0) {
+ for (const [id, inst] of Object.entries(instanceData)) {
+ const status = getInstanceDownloadStatus(id, inst);
+ if (
+ status.statusText === "READY" ||
+ status.statusText === "LOADED" ||
+ status.statusText === "RUNNING"
+ ) {
+ onboardingStep = 7;
+ break;
+ }
+ }
+ }
+ });
+
+ function completeOnboarding() {
+ onboardingStep = 0;
+ try {
+ localStorage.setItem(ONBOARDING_COMPLETE_KEY, "true");
+ } catch {
+ // ignore
+ }
+ }
+
+ let onboardingError = $state<string | null>(null);
+
+ async function onboardingLaunchModel(modelId: string) {
+ onboardingModelId = modelId;
+ onboardingError = null;
+ selectPreviewModel(modelId);
+ onboardingStep = 5;
+ // Launch via API
+ 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 = `Could not place model: ${errorText}`;
+ onboardingStep = 4;
+ return;
+ }
+ const placementData = await placementResponse.json();
+ const response = await fetch("/instance", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ instance: placementData }),
+ });
+ if (!response.ok) {
+ const errorText = await response.text();
+ onboardingError = `Failed to launch: ${errorText}`;
+ onboardingStep = 4;
+ return;
+ }
+ setSelectedChatModel(modelId);
+ recordRecentLaunch(modelId);
+ } catch (error) {
+ onboardingError = `Network error: ${error}`;
+ onboardingStep = 4;
+ }
+ }
+
+ // 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;
+ });
+
// Instance launch state
let models = $state<
Array<{
@@ -374,6 +562,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());
@@ -703,6 +894,20 @@
onMount(() => {
mounted = true;
fetchModels();
+
+ // 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;
+ }
+
+ // Show onboarding wizard for first-time users
+ if (!localStorage.getItem(ONBOARDING_COMPLETE_KEY)) {
+ onboardingStep = 1;
+ }
});
async function fetchModels() {
@@ -782,7 +987,30 @@
? Array.from(nodeFilter)
: undefined;
- const response = await fetch("/meta_instance", {
+ if (preview?.instance) {
+ // Use the instance from the preview
+ instanceData = preview.instance;
+ } else {
+ // Fallback: GET placement from API
+ const placementResponse = await fetch(
+ `/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${selectedSharding}&instance_meta=${selectedInstanceType}&min_nodes=${selectedMinNodes}`,
+ );
+
+ if (!placementResponse.ok) {
+ const errorText = await placementResponse.text();
+ console.error("Failed to get placement:", errorText);
+ addToast({
+ type: "error",
+ message: `Placement failed: ${errorText}`,
+ });
+ return;
+ }
+
+ instanceData = await placementResponse.json();
+ }
+
+ // POST the instance to create it
+ const response = await fetch("/instance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -796,8 +1024,13 @@
if (!response.ok) {
const errorText = await response.text();
- console.error("Failed to create meta instance:", errorText);
+ console.error("Failed to launch instance:", errorText);
+ addToast({
+ type: "error",
+ message: `Failed to launch model: ${errorText}`,
+ });
} else {
+ addToast({ type: "success", message: `Model launched successfully` });
// Always auto-select the newly launched model so the user chats to what they just launched
setSelectedChatModel(modelId);
@@ -819,7 +1052,11 @@
setTimeout(scrollToBottom, 1000);
}
} catch (error) {
- console.error("Error creating meta instance:", error);
+ console.error("Error launching instance:", error);
+ addToast({
+ type: "error",
+ message: "Failed to launch model. Check console for details.",
+ });
} finally {
launchingModelId = null;
}
@@ -1428,6 +1665,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
@@ -2416,368 +2654,1385 @@
class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden"
>
<!-- Scanline overlay -->
- <div
- class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"
- ></div>
-
- <!-- Shooting Stars Background - one every ~15s -->
- <div class="shooting-stars">
+ {#if !showOnboarding}
<div
- class="shooting-star"
- style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"
+ class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"
></div>
- <div
- class="shooting-star"
- style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"
- ></div>
- <div
- class="shooting-star"
- style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"
- ></div>
- </div>
-
- {#if !topologyOnlyEnabled}
- <HeaderNav
- showHome={chatStarted}
- onHome={handleGoHome}
- showSidebarToggle={true}
- {sidebarVisible}
- onToggleSidebar={toggleChatSidebarVisible}
- />
- {/if}
-
- <!-- Main Content -->
- <main class="flex-1 flex overflow-hidden relative">
- <!-- Left: Conversation History Sidebar (hidden in topology-only mode or when toggled off) -->
- {#if !topologyOnlyEnabled && sidebarVisible}
- <div class="w-80 flex-shrink-0 border-r border-exo-yellow/10">
- <ChatSidebar class="h-full" />
- </div>
- {/if}
- {#if topologyOnlyEnabled}
- <!-- TOPOLOGY ONLY MODE: Full-screen topology -->
+ <!-- Shooting Stars Background - one every ~15s -->
+ <div class="shooting-stars">
+ <div
+ class="shooting-star"
+ style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"
+ ></div>
<div
- class="flex-1 flex flex-col min-h-0 min-w-0 p-4"
- in:fade={{ duration: 300 }}
- >
+ class="shooting-star"
+ style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"
+ ></div>
+ <div
+ class="shooting-star"
+ style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"
+ ></div>
+ </div>
+ {/if}
+
+ {#if showOnboarding}
+ <!-- ═══════════════════════════════════════════════════════ -->
+ <!-- FULL-SCREEN ONBOARDING WIZARD -->
+ <!-- ═══════════════════════════════════════════════════════ -->
+ <div class="flex-1 flex items-center justify-center relative z-10 bg-white">
+ {#if onboardingStep === 1}
+ <!-- Step 1: Welcome -->
<div
- class="flex-1 relative bg-exo-dark-gray/40 rounded-lg overflow-hidden"
+ class="text-center max-w-lg px-8"
+ in:fade={{ duration: 400 }}
+ out:fade={{ duration: 200 }}
>
- <TopologyGraph
- class="w-full h-full"
- highlightedNodes={highlightedNodes()}
- filteredNodes={nodeFilter}
- onNodeClick={togglePreviewNodeFilter}
- />
-
- {@render clusterWarnings()}
-
- <!-- TB5 RDMA Available Info -->
- {#if tb5WithoutRdma && !tb5InfoDismissed}
+ <div class="mb-8">
<div
- class="absolute left-4 flex items-center gap-2 px-3 py-2 rounded border border-blue-400/50 bg-blue-400/10 backdrop-blur-sm"
- class:top-16={tbBridgeCycles.length > 0}
- class:top-4={tbBridgeCycles.length === 0}
- role="status"
+ class="text-5xl font-mono font-bold text-exo-yellow tracking-wider mb-4"
>
- <svg
- class="w-5 h-5 text-blue-400 flex-shrink-0"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
- />
- </svg>
- <span class="text-sm font-mono text-blue-200">
- RDMA AVAILABLE
- </span>
- <button
- type="button"
- onclick={() => (tb5InfoDismissed = true)}
- class="ml-1 text-blue-300/60 hover:text-blue-200 transition-colors cursor-pointer"
- title="Dismiss"
- >
- <svg
- class="w-4 h-4"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M6 18L18 6M6 6l12 12"
- />
- </svg>
- </button>
+ exo
</div>
- {/if}
-
- <!-- Exit topology-only mode button -->
+ <h1
+ class="text-2xl font-sans font-light text-gray-900 mb-3 tracking-wide"
+ >
+ Welcome to exo
+ </h1>
+ <p class="text-base font-sans text-gray-500 leading-relaxed">
+ Run AI models locally, across all your devices.
+ </p>
+ </div>
<button
type="button"
- onclick={toggleTopologyOnlyMode}
- class="absolute bottom-4 right-4 p-2 rounded border border-exo-yellow/30 bg-exo-dark-gray/80 hover:border-exo-yellow/50 hover:bg-exo-dark-gray transition-colors cursor-pointer backdrop-blur-sm"
- title="Exit topology only mode"
+ onclick={() => (onboardingStep = 2)}
+ class="inline-flex items-center gap-2 px-8 py-3 bg-exo-yellow text-exo-black font-sans text-sm font-semibold rounded-full hover:brightness-110 hover:shadow-[0_0_24px_rgba(255,215,0,0.2)] transition-all duration-200 cursor-pointer"
>
+ Get Started
<svg
- class="w-5 h-5 text-exo-yellow"
+ class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
- stroke-width="2"
+ stroke-width="2.5"
>
- <circle cx="12" cy="5" r="2" fill="currentColor" />
- <circle cx="5" cy="19" r="2" fill="currentColor" />
- <circle cx="19" cy="19" r="2" fill="currentColor" />
- <path stroke-linecap="round" d="M12 7v5m0 0l-5 5m5-5l5 5" />
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M13 7l5 5m0 0l-5 5m5-5H6"
+ />
</svg>
</button>
</div>
- </div>
- {:else if !chatStarted}
- <!-- WELCOME STATE: Topology + Instance Controls (no left sidebar for cleaner look) -->
- <div
- class="flex-1 flex overflow-visible relative"
- in:fade={{ duration: 300 }}
- out:fade={{ duration: 200 }}
- >
- <!-- Center: MAIN TOPOLOGY DISPLAY -->
- <div class="flex-1 flex flex-col min-h-0 min-w-0 py-4">
- <!-- Topology Container - Takes most of the space -->
- <div
- class="flex-1 relative bg-exo-dark-gray/40 mx-4 mb-4 rounded-lg overflow-hidden"
- >
- <!-- The main topology graph - full container -->
- <TopologyGraph
- class="w-full h-full"
- highlightedNodes={highlightedNodes()}
- filteredNodes={nodeFilter}
- onNodeClick={togglePreviewNodeFilter}
- />
-
- {@render clusterWarnings()}
+ {:else if onboardingStep === 2}
+ <!-- Step 2: Add more devices, run bigger models -->
+ <div
+ class="flex flex-col items-center w-full max-w-2xl px-8"
+ in:fade={{ duration: 400 }}
+ out:fade={{ duration: 200 }}
+ >
+ <div class="text-center mb-4">
+ <h1
+ class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
+ >
+ Add more devices, run bigger models
+ </h1>
+ </div>
- <!-- TB5 RDMA Available Info -->
- {#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-blue-400/50 bg-blue-400/10 backdrop-blur-sm"
+ <!-- Animation stage -->
+ <div class="relative w-full" style="height: 380px;">
+ <svg
+ viewBox="0 0 600 380"
+ class="w-full h-full"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <defs>
+ <filter
+ id="onb-gold-glow"
+ x="-50%"
+ y="-50%"
+ width="200%"
+ height="200%"
>
- <svg
- class="w-5 h-5 text-blue-400 flex-shrink-0"
+ <feGaussianBlur stdDeviation="4" result="blur" />
+ <feFlood
+ flood-color="#FFD700"
+ flood-opacity="0.6"
+ result="color"
+ />
+ <feComposite
+ in="color"
+ in2="blur"
+ operator="in"
+ result="glow"
+ />
+ <feMerge>
+ <feMergeNode in="glow" />
+ <feMergeNode in="SourceGraphic" />
+ </feMerge>
+ </filter>
+ <filter
+ id="onb-device-glow"
+ x="-50%"
+ y="-50%"
+ width="200%"
+ height="200%"
+ >
+ <feGaussianBlur stdDeviation="4" result="blur" />
+ <feFlood
+ flood-color="#FFD700"
+ flood-opacity="0.3"
+ result="color"
+ />
+ <feComposite
+ in="color"
+ in2="blur"
+ operator="in"
+ result="glow"
+ />
+ <feMerge>
+ <feMergeNode in="glow" />
+ <feMergeNode in="SourceGraphic" />
+ </feMerge>
+ </filter>
+ </defs>
+
+ <!-- MacBook Device (left side) -->
+ {#if deviceAnimPhase >= 1}
+ <g transform="translate(135, 30)" in:fade={{ duration: 600 }}>
+ <!-- Screen bezel -->
+ <rect
+ x="0"
+ y="0"
+ width="84"
+ height="56"
+ rx="4"
fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
+ stroke="#FFD700"
+ stroke-width="1.5"
+ filter="url(#onb-device-glow)"
+ />
+ <!-- Screen interior -->
+ <rect
+ x="4"
+ y="3"
+ width="76"
+ height="48"
+ rx="2"
+ fill="#0a0a0a"
+ />
+ <!-- Memory bar inside screen -->
+ <rect
+ x="10"
+ y="36"
+ width="30"
+ height="8"
+ rx="2"
+ fill="#374151"
+ />
+ <rect
+ x="10"
+ y="36"
+ width="18"
+ height="8"
+ rx="2"
+ fill="rgba(255,215,0,0.4)"
+ />
+ <!-- Keyboard base -->
+ <path
+ d="M -6 60 L 90 60 L 84 70 L 0 70 Z"
+ fill="none"
+ stroke="#FFD700"
+ stroke-width="1.5"
+ />
+ <!-- Label -->
+ <text
+ x="42"
+ y="92"
+ text-anchor="middle"
+ fill="rgba(0,0,0,0.7)"
+ style="font-size: 13px; font-family: system-ui, sans-serif;"
>
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
- />
- </svg>
- <span class="text-sm font-mono text-blue-200">
- RDMA AVAILABLE
- </span>
- <button
- type="button"
- onclick={() => (tb5InfoDismissed = true)}
- class="ml-1 text-blue-300/60 hover:text-blue-200 transition-colors cursor-pointer"
- title="Dismiss"
+ MacBook Pro
+ </text>
+ <text
+ x="42"
+ y="108"
+ text-anchor="middle"
+ fill="rgba(0,0,0,0.35)"
+ style="font-size: 11px; font-family: 'SF Mono', monospace;"
>
- <svg
- class="w-4 h-4"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M6 18L18 6M6 6l12 12"
- />
- </svg>
- </button>
- </div>
+ 36 GB
+ </text>
+ </g>
+ {/if}
- <!-- Tooltip on hover -->
- <div
- class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-blue-400/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
+ <!-- Mac Studio Device (right side) - tweened fly-in -->
+ <g transform="translate({$studioX}, 30)" opacity={$studioOpacity}>
+ <!-- Studio box -->
+ <rect
+ x="0"
+ y="0"
+ width="78"
+ height="62"
+ rx="6"
+ fill="none"
+ stroke="#FFD700"
+ stroke-width="1.5"
+ filter="url(#onb-device-glow)"
+ />
+ <!-- Inner face -->
+ <rect
+ x="5"
+ y="5"
+ width="68"
+ height="52"
+ rx="4"
+ fill="#0a0a0a"
+ />
+ <!-- Memory bar inside -->
+ <rect
+ x="12"
+ y="40"
+ width="54"
+ height="8"
+ rx="2"
+ fill="#374151"
+ />
+ <rect
+ x="12"
+ y="40"
+ width="42"
+ height="8"
+ rx="2"
+ fill="rgba(255,215,0,0.4)"
+ />
+ <!-- Front circle detail -->
+ <circle
+ cx="39"
+ cy="56"
+ r="3.5"
+ fill="none"
+ stroke="rgba(255,215,0,0.4)"
+ stroke-width="1"
+ />
+ <!-- Label -->
+ <text
+ x="39"
+ y="86"
+ text-anchor="middle"
+ fill="rgba(0,0,0,0.7)"
+ style="font-size: 13px; font-family: system-ui, sans-serif;"
>
- <p class="text-xs text-white/80 mb-2">
- Thunderbolt 5 hardware detected on multiple nodes. Enable
- RDMA for significantly faster inter-node communication.
- </p>
- <p class="text-xs text-white/60 mb-1.5">
- <span class="text-blue-300">To enable:</span>
- </p>
- <ol
- class="text-xs text-white/60 list-decimal list-inside space-y-0.5 mb-1.5"
- >
- <li>Connect nodes with TB5 cables</li>
- <li>Boot to Recovery (hold power 10s → Options)</li>
- <li>
- Run
- <code class="text-blue-300 bg-blue-400/10 px-1 rounded"
- >rdma_ctl enable</code
- >
- </li>
- <li>Reboot</li>
- </ol>
- <p class="text-xs text-white/40">
- Requires macOS 26.2+, TB5 cables, and matching OS versions.
- </p>
- </div>
- </div>
- {/if}
+ Mac Studio
+ </text>
+ <text
+ x="39"
+ y="102"
+ text-anchor="middle"
+ fill="rgba(0,0,0,0.35)"
+ style="font-size: 11px; font-family: 'SF Mono', monospace;"
+ >
+ 192 GB
+ </text>
+ </g>
+
+ <!-- Connection line between devices -->
+ {#if deviceAnimPhase >= 3}
+ <line
+ x1="223"
+ y1="62"
+ x2="340"
+ y2="62"
+ class="onboarding-connection-line"
+ in:fade={{ duration: 400 }}
+ />
+ {/if}
- <!-- Node Filter Indicator (top-right corner) -->
- {#if isFilterActive()}
- <button
- onclick={clearPreviewNodeFilter}
- class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 bg-exo-dark-gray/80 border border-exo-yellow/40 rounded text-exo-yellow hover:border-exo-yellow/60 transition-colors cursor-pointer backdrop-blur-sm"
- title="Clear filter"
- >
- <span class="text-[10px] font-mono tracking-wider">
- FILTER: {nodeFilter.size}
- </span>
- <svg
- class="w-3 h-3"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
+ <!-- Combined memory label -->
+ {#if deviceAnimPhase >= 3}
+ <text
+ x="282"
+ y="50"
+ text-anchor="middle"
+ fill="rgba(255,215,0,0.7)"
+ style="font-size: 11px; font-family: 'SF Mono', monospace;"
+ in:fade={{ duration: 400 }}
>
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M6 18L18 6M6 6l12 12"
+ 228 GB combined
+ </text>
+ {/if}
+
+ <!-- Model cards row -->
+ <g transform="translate(0, 200)">
+ <!-- Arrows from devices to models -->
+ {#if deviceAnimPhase >= 3}
+ <line
+ x1="177"
+ y1="-50"
+ x2="177"
+ y2="-8"
+ stroke="rgba(255,215,0,0.15)"
+ stroke-width="1"
+ stroke-dasharray="4,4"
+ in:fade={{ duration: 300 }}
/>
- </svg>
- </button>
- {/if}
- </div>
+ <line
+ x1="379"
+ y1="-50"
+ x2="379"
+ y2="-8"
+ stroke="rgba(255,215,0,0.15)"
+ stroke-width="1"
+ stroke-dasharray="4,4"
+ in:fade={{ duration: 300 }}
+ />
+ {/if}
- <!-- Chat Input - Below topology -->
- <div class="px-4 pt-6 pb-8">
- <div class="max-w-3xl mx-auto">
- <ChatForm
- placeholder="Ask anything"
- showHelperText={false}
- showModelSelector={true}
- modelTasks={modelTasks()}
- modelCapabilities={modelCapabilities()}
- />
- </div>
- </div>
- </div>
+ <!-- Small model: 8B (visible from phase 1) -->
+ {#if deviceAnimPhase >= 1}
+ <g transform="translate(55, 0)" in:fade={{ duration: 500 }}>
+ <rect
+ x="0"
+ y="0"
+ width="110"
+ height="48"
+ rx="8"
+ fill="rgba(0,0,0,0.04)"
+ stroke="rgba(0,0,0,0.1)"
+ stroke-width="1"
+ />
+ <text
+ x="55"
+ y="20"
+ text-anchor="middle"
+ fill="rgba(0,0,0,0.7)"
+ style="font-size: 13px; font-family: system-ui, sans-serif;"
+ >
+ Qwen3 8B
+ </text>
+ <text
+ x="55"
+ y="37"
+ text-anchor="middle"
+ fill="rgba(0,0,0,0.35)"
+ style="font-size: 11px; font-family: 'SF Mono', monospace;"
+ >
+ 4 GB
+ </text>
+ </g>
+ {/if}
- <!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) -->
- <aside
- class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0"
- >
- <!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
- {#if instanceCount > 0}
- <div class="p-4 flex-shrink-0">
- <!-- Panel Header -->
- <div class="flex items-center gap-2 mb-4">
- <div
- class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
- ></div>
- <h3
- class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
- >
- Instances
- </h3>
- <div
- class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
- ></div>
- </div>
+ <!-- Medium model: 30B (locked → unlocks at phase 3) -->
+ {#if deviceAnimPhase >= 1}
+ <g
+ transform="translate(185, 0)"
+ in:fade={{ duration: 500, delay: 200 }}
+ >
+ <rect
+ x="0"
+ y="0"
+ width="110"
+ height="48"
+ rx="8"
+ fill={deviceAnimPhase >= 3
+ ? "rgba(255,215,0,0.06)"
+ : "rgba(0,0,0,0.03)"}
+ stroke={deviceAnimPhase >= 3
+ ? "rgba(255,215,0,0.35)"
+ : "rgba(0,0,0,0.08)"}
+ stroke-width="1"
+ filter={deviceAnimPhase >= 3
+ ? "url(#onb-gold-glow)"
+ : "none"}
+ style="transition: fill 500ms, stroke 500ms, filter 500ms;"
+ />
+ {#if deviceAnimPhase < 3}
+ <g transform="translate(48, 12)">
+ <rect
+ x="0"
+ y="9"
+ width="14"
+ height="11"
+ rx="2"
+ fill="none"
+ stroke="rgba(0,0,0,0.2)"
+ stroke-width="1"
+ />
+ <path
+ d="M 2 9 V 6 C 2 3 4.5 1 7 1 C 9.5 1 12 3 12 6 V 9"
+ fill="none"
+ stroke="rgba(0,0,0,0.2)"
+ stroke-width="1"
+ />
+ </g>
+ {:else}
+ <text
+ x="55"
+ y="20"
+ text-anchor="middle"
+ fill="#FFD700"
+ style="font-size: 13px; font-family: system-ui, sans-serif;"
+ >
+ Qwen3 30B
+ </text>
+ <text
+ x="55"
+ y="37"
+ text-anchor="middle"
+ fill="rgba(255,215,0,0.6)"
+ style="font-size: 11px; font-family: 'SF Mono', monospace;"
+ >
+ 16 GB
+ </text>
+ {/if}
+ </g>
+ {/if}
- <div
- bind:this={instancesContainerRef}
- class="max-h-72 xl:max-h-96 space-y-3 overflow-y-auto overflow-x-hidden py-px"
- >
- {#each unifiedDisplayItems as item (item.id)}
- {@const id = item.id}
- {@const instance = item.instance}
- {@const downloadInfo = instance
- ? getInstanceDownloadStatus(item.instanceId ?? id, instance)
- : getMetaInstancePlacingStatus(id)}
- {@const metaData = item.isMetaInstance
- ? metaInstancesData[id]
- : null}
- {@const retryError =
- metaData?.lastFailureError && !downloadInfo.isFailed
- ? metaData.consecutiveFailures > 0
- ? `(${((metaData.consecutiveFailures - 1) % 3) + 1}/3) ${metaData.lastFailureError}`
- : metaData.lastFailureError
- : null}
- {@const statusText = downloadInfo.statusText}
- {@const isDownloading = downloadInfo.isDownloading}
- {@const isFailed =
- statusText === "FAILED" ||
- statusText === "PLACEMENT FAILED"}
- {@const isLoading =
- statusText.startsWith("LOADING") ||
- statusText.startsWith("WARMING UP") ||
- statusText === "WAITING" ||
- statusText === "PLACING" ||
- statusText.startsWith("RETRYING")}
- {@const isReady =
- statusText === "READY" || statusText === "LOADED"}
- {@const isRunning = statusText === "RUNNING"}
- <!-- Instance Card -->
- {@const instanceModelId = item.modelId}
- {@const instanceInfo = instance
- ? getInstanceInfo(instance)
- : {
- instanceType:
- item.instanceMeta === "MlxRing"
- ? "MLX Ring"
- : item.instanceMeta === "MlxJaccl"
- ? "MLX RDMA"
- : "Unknown",
- sharding: item.sharding ?? "Unknown",
- nodeNames: [] as string[],
- nodeIds: [] as string[],
- nodeCount: 0,
- }}
- {@const instanceConnections = instance
- ? getInstanceConnections(instance)
- : []}
+ <!-- Large model: 72B (locked → unlocks at phase 4) -->
+ {#if deviceAnimPhase >= 1}
+ <g
+ transform="translate(315, 0)"
+ in:fade={{ duration: 500, delay: 400 }}
+ >
+ <rect
+ x="0"
+ y="0"
+ width="110"
+ height="48"
+ rx="8"
+ fill={deviceAnimPhase >= 4
+ ? "rgba(255,215,0,0.06)"
+ : "rgba(0,0,0,0.03)"}
+ stroke={deviceAnimPhase >= 4
+ ? "rgba(255,215,0,0.35)"
+ : "rgba(0,0,0,0.08)"}
+ stroke-width="1"
+ filter={deviceAnimPhase >= 4
+ ? "url(#onb-gold-glow)"
+ : "none"}
+ style="transition: fill 500ms, stroke 500ms, filter 500ms;"
+ />
+ {#if deviceAnimPhase < 4}
+ <g transform="translate(48, 12)">
+ <rect
+ x="0"
+ y="9"
+ width="14"
+ height="11"
+ rx="2"
+ fill="none"
+ stroke="rgba(0,0,0,0.2)"
+ stroke-width="1"
+ />
+ <path
+ d="M 2 9 V 6 C 2 3 4.5 1 7 1 C 9.5 1 12 3 12 6 V 9"
+ fill="none"
+ stroke="rgba(0,0,0,0.2)"
+ stroke-width="1"
+ />
+ </g>
+ {:else}
+ <text
+ x="55"
+ y="20"
+ text-anchor="middle"
+ fill="#FFD700"
+ style="font-size: 13px; font-family: system-ui, sans-serif;"
+ >
+ Llama 72B
+ </text>
+ <text
+ x="55"
+ y="37"
+ text-anchor="middle"
+ fill="rgba(255,215,0,0.6)"
+ style="font-size: 11px; font-family: 'SF Mono', monospace;"
+ >
+ 36 GB
+ </text>
+ {/if}
+ </g>
+ {/if}
+
+ <!-- Extra large model: 405B (locked → unlocks at phase 4 with extra glow) -->
+ {#if deviceAnimPhase >= 1}
+ <g
+ transform="translate(445, 0)"
+ in:fade={{ duration: 500, delay: 600 }}
+ >
+ <rect
+ x="0"
+ y="0"
+ width="110"
+ height="48"
+ rx="8"
+ fill={deviceAnimPhase >= 4
+ ? "rgba(255,215,0,0.08)"
+ : "rgba(0,0,0,0.03)"}
+ stroke={deviceAnimPhase >= 4
+ ? "rgba(255,215,0,0.45)"
+ : "rgba(0,0,0,0.08)"}
+ stroke-width={deviceAnimPhase >= 4 ? "1.5" : "1"}
+ filter={deviceAnimPhase >= 4
+ ? "url(#onb-gold-glow)"
+ : "none"}
+ style="transition: fill 700ms, stroke 700ms, filter 700ms, stroke-width 700ms;"
+ />
+ {#if deviceAnimPhase < 4}
+ <g transform="translate(48, 12)">
+ <rect
+ x="0"
+ y="9"
+ width="14"
+ height="11"
+ rx="2"
+ fill="none"
+ stroke="rgba(0,0,0,0.2)"
+ stroke-width="1"
+ />
+ <path
+ d="M 2 9 V 6 C 2 3 4.5 1 7 1 C 9.5 1 12 3 12 6 V 9"
+ fill="none"
+ stroke="rgba(0,0,0,0.2)"
+ stroke-width="1"
+ />
+ </g>
+ {:else}
+ <text
+ x="55"
+ y="20"
+ text-anchor="middle"
+ fill="#FFD700"
+ style="font-size: 13px; font-family: system-ui, sans-serif; font-weight: 600;"
+ >
+ Llama 405B
+ </text>
+ <text
+ x="55"
+ y="37"
+ text-anchor="middle"
+ fill="rgba(255,215,0,0.6)"
+ style="font-size: 11px; font-family: 'SF Mono', monospace;"
+ >
+ 203 GB
+ </text>
+ {/if}
+ </g>
+ {/if}
+
+ <!-- "Models you can run" label -->
+ {#if deviceAnimPhase >= 1}
+ <text
+ x="300"
+ y="72"
+ text-anchor="middle"
+ fill="rgba(0,0,0,0.3)"
+ style="font-size: 11px; font-family: system-ui, sans-serif; text-transform: uppercase; letter-spacing: 0.12em;"
+ in:fade={{ duration: 400 }}
+ >
+ Models you can run
+ </text>
+ {/if}
+ </g>
+ </svg>
+ </div>
+
+ <!-- Continue button -->
+ {#if showContinueStep2}
+ <button
+ type="button"
+ onclick={() => (onboardingStep = 3)}
+ class="inline-flex items-center gap-2 px-8 py-3 mt-2 bg-exo-yellow text-exo-black font-sans text-sm font-semibold rounded-full hover:brightness-110 hover:shadow-[0_0_24px_rgba(255,215,0,0.2)] transition-all duration-200 cursor-pointer"
+ in:fade={{ duration: 400 }}
+ >
+ 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>
+ {/if}
+ </div>
+ {:else if onboardingStep === 3}
+ <!-- Step 3: Your Devices -->
+ <div
+ class="flex flex-col items-center w-full max-w-4xl px-8"
+ in:fade={{ duration: 400 }}
+ out:fade={{ duration: 200 }}
+ >
+ <div class="text-center mb-6">
+ <h1
+ class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
+ >
+ Your devices
+ </h1>
+ <p class="text-sm font-sans text-gray-500">
+ {nodeCount} device{nodeCount !== 1 ? "s" : ""} connected
+ {#if clusterTotalMemoryGB() > 0}
+ · {clusterTotalMemoryGB().toFixed(0)} GB total memory
+ {/if}
+ </p>
+ </div>
+ <div
+ class="w-full h-80 bg-exo-dark-gray rounded-lg overflow-hidden border border-gray-200 mb-8"
+ >
+ <TopologyGraph
+ class="w-full h-full"
+ highlightedNodes={highlightedNodes()}
+ filteredNodes={nodeFilter}
+ onNodeClick={togglePreviewNodeFilter}
+ />
+ </div>
+ <p
+ class="text-sm font-sans text-gray-400 mt-2 max-w-md text-center leading-relaxed"
+ >
+ Install exo on more devices on your network to combine their power —
+ they connect automatically.
+ </p>
+ <button
+ type="button"
+ onclick={() => (onboardingStep = 4)}
+ class="inline-flex items-center gap-2 px-8 py-3 mt-6 bg-exo-yellow text-exo-black font-sans text-sm font-semibold rounded-full hover:brightness-110 hover:shadow-[0_0_24px_rgba(255,215,0,0.2)] transition-all duration-200 cursor-pointer"
+ >
+ 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>
+ {:else if onboardingStep === 4}
+ <!-- Step 4: Choose a Model -->
+ <div
+ class="flex flex-col items-center w-full max-w-2xl px-8"
+ in:fade={{ duration: 400 }}
+ out:fade={{ duration: 200 }}
+ >
+ <div class="text-center mb-8">
+ <h1
+ class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
+ >
+ Choose a model
+ </h1>
+ <p class="text-sm font-sans text-gray-500">
+ Pick a model to download and run locally.
+ </p>
+ </div>
+
+ {#if onboardingError}
+ <div
+ class="w-full mb-6 px-4 py-3 rounded-lg border border-red-200 bg-red-50 text-sm font-mono text-red-600"
+ in:fade={{ duration: 200 }}
+ >
+ {onboardingError}
+ </div>
+ {/if}
+
+ {#if onboardingModels.length === 0}
+ <div class="text-center py-8">
+ <div class="text-sm text-gray-400 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-gray-200 bg-gray-50 hover:border-exo-yellow/50 hover:bg-yellow-50/50'
+ : 'border-gray-200 bg-gray-50/50 hover:border-gray-300 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-gray-900 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-gray-400 truncate"
+ >{model.id}</span
+ >
+ </div>
+ <div class="flex items-center gap-3 flex-shrink-0">
+ <span class="text-xs font-mono text-gray-500"
+ >{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)} GB</span
+ >
+ <svg
+ class="w-4 h-4 text-gray-400"
+ 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-gray-400 hover:text-exo-yellow transition-colors cursor-pointer underline underline-offset-4 decoration-gray-300 hover:decoration-exo-yellow/30"
+ >
+ Browse all models
+ </button>
+ </div>
+ {:else if onboardingStep === 5}
+ <!-- Step 5: Downloading -->
+ <div
+ class="text-center max-w-lg px-8"
+ in:fade={{ duration: 400 }}
+ out:fade={{ duration: 200 }}
+ >
+ <div class="mb-8">
+ <h1
+ class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
+ >
+ Downloading
+ </h1>
+ <p class="text-sm text-gray-500">
+ {#if onboardingModelId}
+ <span class="text-exo-yellow">{onboardingModelId}</span>
+ {/if}
+ </p>
+ </div>
+
+ {#if onboardingDownloadProgress}
+ <div class="w-full max-w-md mx-auto space-y-4">
+ <div
+ class="relative h-2 bg-gray-200 rounded-full overflow-hidden"
+ >
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-amber-400 rounded-full transition-all duration-500"
+ style="width: {onboardingDownloadProgress.percentage}%"
+ ></div>
+ </div>
+ <div class="flex justify-between text-xs font-mono text-gray-500">
+ <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-gray-400">
+ <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-gray-200 rounded-full overflow-hidden"
+ >
+ <div
+ class="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-exo-yellow to-amber-400 rounded-full animate-pulse"
+ ></div>
+ </div>
+ <p class="text-xs font-mono text-gray-400 mt-4">
+ Preparing download...
+ </p>
+ </div>
+ {/if}
+
+ <p class="text-xs font-sans text-gray-300 mt-8">
+ This may take a few minutes depending on your connection.
+ </p>
+ </div>
+ {:else if onboardingStep === 6}
+ <!-- Step 6: Loading into memory -->
+ <div
+ class="text-center max-w-lg px-8"
+ in:fade={{ duration: 400 }}
+ out:fade={{ duration: 200 }}
+ >
+ <div class="mb-8">
+ <h1
+ class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
+ >
+ Loading into memory
+ </h1>
+ <p class="text-sm text-gray-500">
+ {#if onboardingModelId}
+ <span class="text-exo-yellow">{onboardingModelId}</span>
+ {/if}
+ </p>
+ </div>
+
+ <div class="flex justify-center mb-6">
+ <div
+ class="w-12 h-12 border-2 border-exo-yellow/30 border-t-exo-yellow rounded-full animate-spin"
+ ></div>
+ </div>
+
+ <p class="text-sm text-gray-400 font-sans">Almost ready...</p>
+ </div>
+ {:else if onboardingStep === 7}
+ <!-- Step 7: Chat — centered input auto-appears, first message transitions to dashboard -->
+ <div
+ class="flex flex-col items-center justify-center w-full max-w-2xl px-8"
+ in:fade={{ duration: 400 }}
+ out:fade={{ duration: 200 }}
+ >
+ <!-- Subtle branding -->
+ <div
+ class="text-2xl font-mono text-gray-200 font-bold tracking-wider mb-8"
+ >
+ exo
+ </div>
+
+ <!-- Model name -->
+ {#if onboardingModelId}
+ <p class="text-sm text-gray-400 font-sans mb-4">
+ {onboardingModelId.split("/").pop() ?? onboardingModelId}
+ </p>
+ {/if}
+
+ <!-- Centered ChatForm — first message completes onboarding -->
+ <div class="w-full bg-exo-dark-gray rounded-xl p-4">
+ <ChatForm
+ placeholder="Ask anything"
+ autofocus={true}
+ showHelperText={false}
+ showModelSelector={false}
+ modelTasks={modelTasks()}
+ modelCapabilities={modelCapabilities()}
+ onSend={completeOnboarding}
+ />
+ </div>
+
+ <!-- Suggestion chips -->
+ <div class="flex flex-wrap justify-center gap-2.5 mt-8">
+ {#each ["Write a poem about the ocean", "Explain quantum computing simply", "Help me debug my code", "Tell me a creative story"] as chip}
+ <button
+ type="button"
+ onclick={() => {
+ sendMessage(chip);
+ completeOnboarding();
+ }}
+ class="px-4 py-2 rounded-full border border-gray-200 bg-gray-50 text-[13px] font-sans text-gray-500 hover:bg-gray-100 hover:text-gray-700 hover:border-gray-300 transition-all duration-200 cursor-pointer"
+ >
+ {chip}
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Model Picker Modal (available during onboarding step 4) -->
+ {#if onboardingStep === 4}
+ <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}
+ {:else}
+ <!-- ═══════════════════════════════════════════════════════ -->
+ <!-- MAIN DASHBOARD (shown after onboarding) -->
+ <!-- ═══════════════════════════════════════════════════════ -->
+ {#if !topologyOnlyEnabled}
+ <HeaderNav
+ showHome={chatStarted}
+ 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 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 Available Info -->
+ {#if tb5WithoutRdma && !tb5InfoDismissed}
+ <div
+ class="absolute left-4 flex items-center gap-2 px-3 py-2 rounded border border-blue-400/50 bg-blue-400/10 backdrop-blur-sm"
+ class:top-16={tbBridgeCycles.length > 0}
+ class:top-4={tbBridgeCycles.length === 0}
+ role="status"
+ >
+ <svg
+ class="w-5 h-5 text-blue-400 flex-shrink-0"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ <span class="text-sm font-mono text-blue-200">
+ RDMA AVAILABLE
+ </span>
+ <button
+ type="button"
+ onclick={() => (tb5InfoDismissed = true)}
+ class="ml-1 text-blue-300/60 hover:text-blue-200 transition-colors cursor-pointer"
+ title="Dismiss"
+ aria-label="Dismiss RDMA available notification"
+ >
+ <svg
+ class="w-4 h-4"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ </div>
+ {/if}
+
+ <!-- Exit topology-only mode button -->
+ <button
+ type="button"
+ onclick={toggleTopologyOnlyMode}
+ class="absolute bottom-4 right-4 p-2 rounded border border-exo-yellow/30 bg-exo-dark-gray/80 hover:border-exo-yellow/50 hover:bg-exo-dark-gray transition-colors cursor-pointer backdrop-blur-sm"
+ title="Exit topology only mode"
+ aria-label="Exit topology only mode"
+ >
+ <svg
+ class="w-5 h-5 text-exo-yellow"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <circle cx="12" cy="5" r="2" fill="currentColor" />
+ <circle cx="5" cy="19" r="2" fill="currentColor" />
+ <circle cx="19" cy="19" r="2" fill="currentColor" />
+ <path stroke-linecap="round" d="M12 7v5m0 0l-5 5m5-5l5 5" />
+ </svg>
+ </button>
+ </div>
+ </div>
+ {:else if !chatStarted}
+ <!-- WELCOME STATE: Topology + Instance Controls (no left sidebar for cleaner look) -->
+ <div
+ class="flex-1 flex overflow-visible relative"
+ in:fade={{ duration: 300 }}
+ out:fade={{ duration: 200 }}
+ >
+ <!-- Center: MAIN TOPOLOGY DISPLAY -->
+ <div class="flex-1 flex flex-col min-h-0 min-w-0 py-4">
+ <!-- Topology Container - Takes most of the space -->
+ <div
+ class="flex-1 relative bg-exo-dark-gray/40 mx-4 mb-4 rounded-lg overflow-hidden"
+ >
+ <!-- The main topology graph - full container -->
+ <TopologyGraph
+ class="w-full h-full"
+ highlightedNodes={highlightedNodes()}
+ filteredNodes={nodeFilter}
+ 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}
+
+ <!-- Welcome overlay - shown when no instances are running -->
+ {#if instanceCount === 0 && update}
+ <div
+ class="absolute inset-0 flex items-center justify-center pointer-events-none"
+ in:fade={{ duration: 400, delay: 200 }}
+ >
+ <div class="text-center pointer-events-auto max-w-lg px-6">
+ <div class="mb-6">
+ <div
+ class="text-2xl font-mono text-exo-yellow font-bold tracking-wide mb-3 glow-text"
+ >
+ exo
+ </div>
+ <p
+ class="text-sm font-sans text-white/50 leading-relaxed mb-1"
+ >
+ {#if data && Object.keys(data.nodes).length > 1}
+ {Object.keys(data.nodes).length} devices connected. Choose
+ a model to start running AI across your cluster.
+ {:else if data && Object.keys(data.nodes).length === 1}
+ Your device is ready. Choose a model to start running
+ AI locally.
+ {:else}
+ Waiting for devices to connect…
+ {/if}
+ </p>
+ </div>
+
+ <button
+ type="button"
+ onclick={() => (isModelPickerOpen = true)}
+ class="inline-flex items-center gap-2 px-6 py-3 bg-exo-yellow text-exo-black font-sans text-sm font-semibold rounded-full hover:brightness-110 hover:shadow-[0_0_24px_rgba(255,215,0,0.2)] transition-all duration-200 cursor-pointer mb-4"
+ >
+ <svg
+ class="w-5 h-5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ Choose a Model
+ </button>
+
+ <!-- Quick hints -->
+ <div
+ class="flex items-center justify-center gap-4 text-xs text-white/30 font-mono"
+ >
+ <span>models download automatically</span>
+ <span class="text-white/15">•</span>
+ <a
+ href="/#/downloads"
+ class="hover:text-exo-yellow/60 transition-colors"
+ >view downloads</a
+ >
+ </div>
+ </div>
+ </div>
+ {/if}
+
+ {@render clusterWarnings()}
+
+ <!-- TB5 RDMA Available Info -->
+ {#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-blue-400/50 bg-blue-400/10 backdrop-blur-sm"
+ >
+ <svg
+ class="w-5 h-5 text-blue-400 flex-shrink-0"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ <span class="text-sm font-mono text-blue-200">
+ RDMA AVAILABLE
+ </span>
+ <button
+ type="button"
+ onclick={() => (tb5InfoDismissed = true)}
+ class="ml-1 text-blue-300/60 hover:text-blue-200 transition-colors cursor-pointer"
+ title="Dismiss"
+ >
+ <svg
+ class="w-4 h-4"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ </div>
+
+ <!-- Tooltip on hover -->
<div
- class="relative group cursor-pointer"
- role="button"
- tabindex="0"
- onmouseenter={() =>
- (hoveredInstanceId = item.instanceId ?? id)}
- onmouseleave={() => (hoveredInstanceId = null)}
- onclick={() => {
- if (
- instanceModelId &&
- instanceModelId !== "Unknown" &&
- instanceModelId !== "Unknown Model"
- ) {
- setSelectedChatModel(instanceModelId);
- }
- }}
- onkeydown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
+ class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-blue-400/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
+ >
+ <p class="text-xs text-white/80 mb-2">
+ Thunderbolt 5 hardware detected on multiple nodes. Enable
+ RDMA for significantly faster inter-node communication.
+ </p>
+ <p class="text-xs text-white/60 mb-1.5">
+ <span class="text-blue-300">To enable:</span>
+ </p>
+ <ol
+ class="text-xs text-white/60 list-decimal list-inside space-y-0.5 mb-1.5"
+ >
+ <li>Connect nodes with TB5 cables</li>
+ <li>Boot to Recovery (hold power 10s → Options)</li>
+ <li>
+ Run
+ <code class="text-blue-300 bg-blue-400/10 px-1 rounded"
+ >rdma_ctl enable</code
+ >
+ </li>
+ <li>Reboot</li>
+ </ol>
+ <p class="text-xs text-white/40">
+ Requires macOS 26.2+, TB5 cables, and matching OS
+ versions.
+ </p>
+ </div>
+ </div>
+ {/if}
+
+ <!-- Node Filter Indicator (top-right corner) -->
+ {#if isFilterActive()}
+ <button
+ onclick={clearPreviewNodeFilter}
+ class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 bg-exo-dark-gray/80 border border-exo-yellow/40 rounded text-exo-yellow hover:border-exo-yellow/60 transition-colors cursor-pointer backdrop-blur-sm"
+ title="Clear filter"
+ >
+ <span class="text-[10px] font-mono tracking-wider">
+ FILTER: {nodeFilter.size}
+ </span>
+ <svg
+ class="w-3 h-3"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ {/if}
+ </div>
+
+ <!-- Chat Input - Below topology -->
+ <div class="px-4 pt-6 pb-8">
+ <div class="max-w-3xl mx-auto">
+ {#if instanceCount === 0}
+ <!-- No model loaded prompt -->
+ <div class="text-center mb-6">
+ <p class="text-sm text-white/60 font-mono">
+ No model loaded yet. Select a model to get started.
+ </p>
+ </div>
+ {/if}
+ <ChatForm
+ placeholder={instanceCount === 0
+ ? "Choose a model above to start chatting"
+ : "Ask anything"}
+ showHelperText={false}
+ showModelSelector={true}
+ modelTasks={modelTasks()}
+ modelCapabilities={modelCapabilities()}
+ />
+ </div>
+ </div>
+ </div>
+
+ <!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) -->
+ <aside
+ class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0"
+ aria-label="Instance controls"
+ >
+ <!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
+ {#if instanceCount > 0}
+ <div class="p-4 flex-shrink-0">
+ <!-- Panel Header -->
+ <div class="flex items-center gap-2 mb-4">
+ <div
+ class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
+ ></div>
+ <h3
+ class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
+ >
+ Instances
+ </h3>
+ <div
+ class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
+ ></div>
+ </div>
+
+ <div
+ bind:this={instancesContainerRef}
+ class="max-h-72 xl:max-h-96 space-y-3 overflow-y-auto overflow-x-hidden py-px"
+ >
+ {#each unifiedDisplayItems as item (item.id)}
+ {@const id = item.id}
+ {@const instance = item.instance}
+ {@const downloadInfo = instance
+ ? getInstanceDownloadStatus(
+ item.instanceId ?? id,
+ instance,
+ )
+ : getMetaInstancePlacingStatus(id)}
+ {@const metaData = item.isMetaInstance
+ ? metaInstancesData[id]
+ : null}
+ {@const retryError =
+ metaData?.lastFailureError && !downloadInfo.isFailed
+ ? metaData.consecutiveFailures > 0
+ ? `(${((metaData.consecutiveFailures - 1) % 3) + 1}/3) ${metaData.lastFailureError}`
+ : metaData.lastFailureError
+ : null}
+ {@const statusText = downloadInfo.statusText}
+ {@const isDownloading = downloadInfo.isDownloading}
+ {@const isFailed =
+ statusText === "FAILED" ||
+ statusText === "PLACEMENT FAILED"}
+ {@const isLoading =
+ statusText.startsWith("LOADING") ||
+ statusText.startsWith("WARMING UP") ||
+ statusText === "WAITING" ||
+ statusText === "PLACING" ||
+ statusText.startsWith("RETRYING")}
+ {@const isReady =
+ statusText === "READY" || statusText === "LOADED"}
+ {@const isRunning = statusText === "RUNNING"}
+ <!-- Instance Card -->
+ {@const instanceModelId = item.modelId}
+ {@const instanceInfo = instance
+ ? getInstanceInfo(instance)
+ : {
+ instanceType:
+ item.instanceMeta === "MlxRing"
+ ? "MLX Ring"
+ : item.instanceMeta === "MlxJaccl"
+ ? "MLX RDMA"
+ : "Unknown",
+ sharding: item.sharding ?? "Unknown",
+ nodeNames: [] as string[],
+ nodeIds: [] as string[],
+ nodeCount: 0,
+ }}
+ {@const instanceConnections = instance
+ ? getInstanceConnections(instance)
+ : []}
+ <div
+ class="relative group cursor-pointer"
+ role="button"
+ tabindex="0"
+ transition:slide={{ duration: 250, easing: cubicOut }}
+ onmouseenter={() => (hoveredInstanceId = id)}
+ onmouseleave={() => (hoveredInstanceId = null)}
+ onclick={() => {
if (
instanceModelId &&
instanceModelId !== "Unknown" &&
@@ -2785,871 +4040,929 @@
) {
setSelectedChatModel(instanceModelId);
}
- }
- }}
- >
- <!-- Corner accents -->
- <div
- class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
- <div
- class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
- <div
- class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
- <div
- class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
-
- <div
- class="bg-exo-dark-gray/60 border border-l-2 {isDownloading
- ? 'border-blue-500/30 border-l-blue-400'
- : isFailed
- ? 'border-red-500/30 border-l-red-400'
- : isLoading
- ? 'border-exo-yellow/30 border-l-yellow-400'
- : isReady
- ? 'border-green-500/30 border-l-green-400'
- : 'border-teal-500/30 border-l-teal-400'} p-3"
+ }}
+ onkeydown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ if (
+ instanceModelId &&
+ instanceModelId !== "Unknown" &&
+ instanceModelId !== "Unknown Model"
+ ) {
+ setSelectedChatModel(instanceModelId);
+ }
+ }
+ }}
>
- <div class="flex justify-between items-start mb-2 pl-2">
- <div class="flex items-center gap-2">
- <div
- class="w-1.5 h-1.5 {isDownloading
- ? 'bg-blue-400 animate-pulse'
- : isFailed
- ? 'bg-red-400'
- : isLoading
- ? 'bg-yellow-400 animate-pulse'
- : isReady
- ? 'bg-green-400'
- : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"
- ></div>
- <span
- class="text-exo-light-gray font-mono text-sm tracking-wider"
- >{id.slice(0, 8).toUpperCase()}</span
+ <!-- Corner accents -->
+ <div
+ class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading
+ ? 'border-blue-500/50'
+ : isFailed
+ ? 'border-red-500/50'
+ : isLoading
+ ? 'border-yellow-500/50'
+ : isReady
+ ? 'border-green-500/50'
+ : 'border-teal-500/50'}"
+ ></div>
+ <div
+ class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading
+ ? 'border-blue-500/50'
+ : isFailed
+ ? 'border-red-500/50'
+ : isLoading
+ ? 'border-yellow-500/50'
+ : isReady
+ ? 'border-green-500/50'
+ : 'border-teal-500/50'}"
+ ></div>
+ <div
+ class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading
+ ? 'border-blue-500/50'
+ : isFailed
+ ? 'border-red-500/50'
+ : isLoading
+ ? 'border-yellow-500/50'
+ : isReady
+ ? 'border-green-500/50'
+ : 'border-teal-500/50'}"
+ ></div>
+ <div
+ class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading
+ ? 'border-blue-500/50'
+ : isFailed
+ ? 'border-red-500/50'
+ : isLoading
+ ? 'border-yellow-500/50'
+ : isReady
+ ? 'border-green-500/50'
+ : 'border-teal-500/50'}"
+ ></div>
+
+ <div
+ class="bg-exo-dark-gray/60 border border-l-2 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 group-hover:border-red-500/50'
+ : isLoading
+ ? 'border-exo-yellow/30 border-l-yellow-400 group-hover:border-exo-yellow/50'
+ : isReady
+ ? '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">
+ <div
+ class="w-1.5 h-1.5 {isDownloading
+ ? 'bg-blue-400 animate-pulse'
+ : isFailed
+ ? 'bg-red-400'
+ : isLoading
+ ? 'bg-yellow-400 animate-pulse'
+ : isReady
+ ? 'bg-green-400'
+ : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"
+ ></div>
+ <span
+ class="text-exo-light-gray font-mono text-sm tracking-wider"
+ >{id.slice(0, 8).toUpperCase()}</span
+ >
+ </div>
+ <button
+ onclick={() =>
+ item.isMetaInstance
+ ? deleteMetaInstance(id)
+ : deleteInstance(id)}
+ class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
>
+ DELETE
+ </button>
</div>
- <button
- onclick={() =>
- item.isMetaInstance
- ? deleteMetaInstance(id)
- : deleteInstance(id)}
- class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
- >
- DELETE
- </button>
- </div>
- <div class="pl-2">
- <div
- class="text-exo-yellow text-xs font-mono tracking-wide truncate"
- >
- {instanceModelId}
- </div>
- <div class="text-white/60 text-xs font-mono">
- Strategy: <span class="text-white/80"
- >{instanceInfo.sharding} ({instanceInfo.instanceType})</span
+ <div class="pl-2">
+ <div
+ class="text-exo-yellow text-xs font-mono tracking-wide truncate"
>
- </div>
- {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
- <a
- class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
- href={`https://huggingface.co/${instanceModelId}`}
- target="_blank"
- rel="noreferrer noopener"
- aria-label="View model on Hugging Face"
+ {instanceModelId}
+ </div>
+ <div
+ class="flex items-center gap-2 text-white/60 text-xs font-mono"
>
- <span>Hugging Face</span>
- <svg
- class="w-3.5 h-3.5"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
+ <span
+ >{instanceInfo.sharding} · {instanceInfo.instanceType}</span
>
- <path d="M14 3h7v7" />
- <path d="M10 14l11-11" />
- <path
- d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
- />
- </svg>
- </a>
- {/if}
- {#if instanceInfo.nodeNames.length > 0}
- <div class="text-white/60 text-xs font-mono">
- {instanceInfo.nodeNames.join(", ")}
+ <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}
- {#if debugEnabled && instanceConnections.length > 0}
- <div class="mt-2 space-y-1">
- {#each instanceConnections as conn}
+ {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
+ <a
+ class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
+ href={`https://huggingface.co/${instanceModelId}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ aria-label="View model on Hugging Face"
+ >
+ <span>Hugging Face</span>
+ <svg
+ class="w-3.5 h-3.5"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <path d="M14 3h7v7" />
+ <path d="M10 14l11-11" />
+ <path
+ d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
+ />
+ </svg>
+ </a>
+ {/if}
+ {#if instanceInfo.nodeNames.length > 0}
+ <div class="text-white/60 text-xs font-mono">
+ {instanceInfo.nodeNames.join(", ")}
+ </div>
+ {/if}
+ {#if debugEnabled && instanceConnections.length > 0}
+ <div class="mt-2 space-y-1">
+ {#each instanceConnections as conn}
+ <div
+ class="text-[11px] leading-snug font-mono text-white/70"
+ >
+ <span
+ >{conn.from} -> {conn.to}: {conn.ip}</span
+ >
+ <span
+ class={conn.missingIface
+ ? "text-red-400"
+ : "text-white/60"}
+ >
+ ({conn.ifaceLabel})</span
+ >
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Download Progress -->
+ {#if downloadInfo.isDownloading && downloadInfo.progress}
+ <div class="mt-2 space-y-1">
+ <div
+ class="flex justify-between text-xs font-mono"
+ >
+ <span class="text-blue-400"
+ >{downloadInfo.progress.percentage.toFixed(
+ 1,
+ )}%</span
+ >
+ <span class="text-exo-light-gray"
+ >{formatBytes(
+ downloadInfo.progress.downloadedBytes,
+ )}/{formatBytes(
+ downloadInfo.progress.totalBytes,
+ )}</span
+ >
+ </div>
+ <div
+ class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+ >
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+ style="width: {downloadInfo.progress
+ .percentage}%"
+ ></div>
+ </div>
<div
- class="text-[11px] leading-snug font-mono text-white/70"
+ class="flex justify-between text-xs font-mono text-exo-light-gray"
>
- <span>{conn.from} -> {conn.to}: {conn.ip}</span>
<span
- class={conn.missingIface
- ? "text-red-400"
- : "text-white/60"}
+ >{formatSpeed(
+ downloadInfo.progress.speed,
+ )}</span
+ >
+ <span
+ >ETA: {formatEta(
+ downloadInfo.progress.etaMs,
+ )}</span
>
- ({conn.ifaceLabel})</span
+ <span
+ >{downloadInfo.progress
+ .completedFiles}/{downloadInfo.progress
+ .totalFiles} files</span
>
</div>
- {/each}
- </div>
- {/if}
-
- <!-- Download Progress -->
- {#if downloadInfo.isDownloading && downloadInfo.progress}
- <div class="mt-2 space-y-1">
- <div class="flex justify-between text-xs font-mono">
- <span class="text-blue-400"
- >{downloadInfo.progress.percentage.toFixed(
- 1,
- )}%</span
- >
- <span class="text-exo-light-gray"
- >{formatBytes(
- downloadInfo.progress.downloadedBytes,
- )}/{formatBytes(
- downloadInfo.progress.totalBytes,
- )}</span
- >
</div>
- <div
- class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
- >
+ {#if downloadInfo.perNode.length > 0}
<div
- class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
- style="width: {downloadInfo.progress
- .percentage}%"
- ></div>
- </div>
- <div
- class="flex justify-between text-xs font-mono text-exo-light-gray"
- >
- <span
- >{formatSpeed(
- downloadInfo.progress.speed,
- )}</span
- >
- <span
- >ETA: {formatEta(
- downloadInfo.progress.etaMs,
- )}</span
- >
- <span
- >{downloadInfo.progress
- .completedFiles}/{downloadInfo.progress
- .totalFiles} files</span
+ class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
>
- </div>
- </div>
- {#if downloadInfo.perNode.length > 0}
- <div
- class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
- >
- {#each downloadInfo.perNode as nodeProg}
- {@const nodePercent = Math.min(
- 100,
- Math.max(0, nodeProg.progress.percentage),
- )}
- {@const isExpanded =
- instanceDownloadExpandedNodes.has(
- nodeProg.nodeId,
+ {#each downloadInfo.perNode as nodeProg}
+ {@const nodePercent = Math.min(
+ 100,
+ Math.max(0, nodeProg.progress.percentage),
)}
- <div
- class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
- >
- <button
- type="button"
- class="w-full text-left space-y-1.5"
- onclick={() =>
- toggleInstanceDownloadDetails(
- nodeProg.nodeId,
- )}
+ {@const isExpanded =
+ instanceDownloadExpandedNodes.has(
+ nodeProg.nodeId,
+ )}
+ <div
+ class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
>
- <div
- class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+ <button
+ type="button"
+ class="w-full text-left space-y-1.5"
+ onclick={() =>
+ toggleInstanceDownloadDetails(
+ nodeProg.nodeId,
+ )}
>
- <span class="text-white/80 truncate pr-2"
- >{nodeProg.nodeName}</span
- >
- <span
- class="flex items-center gap-1 text-blue-300"
+ <div
+ class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
>
- {nodePercent.toFixed(1)}%
- <svg
- class="w-3 h-3 text-exo-light-gray"
- viewBox="0 0 20 20"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
+ <span
+ class="text-white/80 truncate pr-2"
+ >{nodeProg.nodeName}</span
>
- <path
- d="M6 8l4 4 4-4"
- class={isExpanded
- ? "transform rotate-180 origin-center transition-transform duration-150"
- : "transition-transform duration-150"}
- ></path>
- </svg>
- </span>
- </div>
- <div
- class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
- >
+ <span
+ class="flex items-center gap-1 text-blue-300"
+ >
+ {nodePercent.toFixed(1)}%
+ <svg
+ class="w-3 h-3 text-exo-light-gray"
+ viewBox="0 0 20 20"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ d="M6 8l4 4 4-4"
+ class={isExpanded
+ ? "transform rotate-180 origin-center transition-transform duration-150"
+ : "transition-transform duration-150"}
+ ></path>
+ </svg>
+ </span>
+ </div>
<div
- class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
- style="width: {nodePercent.toFixed(1)}%"
- ></div>
- </div>
- <div
- class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
- >
- <span
- >{formatBytes(
- nodeProg.progress.downloadedBytes,
- )} / {formatBytes(
- nodeProg.progress.totalBytes,
- )}</span
- >
- <span
- >{formatSpeed(nodeProg.progress.speed)} •
- ETA {formatEta(
- nodeProg.progress.etaMs,
- )}</span
+ class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
>
- </div>
- </button>
-
- {#if isExpanded}
- <div class="mt-2 space-y-1.5">
- {#if nodeProg.progress.files.length === 0}
<div
- class="text-[11px] font-mono text-exo-light-gray/70"
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+ style="width: {nodePercent.toFixed(
+ 1,
+ )}%"
+ ></div>
+ </div>
+ <div
+ class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+ >
+ <span
+ >{formatBytes(
+ nodeProg.progress.downloadedBytes,
+ )} / {formatBytes(
+ nodeProg.progress.totalBytes,
+ )}</span
>
- No file details reported.
- </div>
- {:else}
- {#each nodeProg.progress.files as f}
- {@const filePercent = Math.min(
- 100,
- Math.max(0, f.percentage ?? 0),
- )}
- {@const isFileComplete =
- filePercent >= 100}
+ <span
+ >{formatSpeed(
+ nodeProg.progress.speed,
+ )} • ETA {formatEta(
+ nodeProg.progress.etaMs,
+ )}</span
+ >
+ </div>
+ </button>
+
+ {#if isExpanded}
+ <div class="mt-2 space-y-1.5">
+ {#if nodeProg.progress.files.length === 0}
<div
- class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
+ class="text-[11px] font-mono text-exo-light-gray/70"
>
+ No file details reported.
+ </div>
+ {:else}
+ {#each nodeProg.progress.files as f}
+ {@const filePercent = Math.min(
+ 100,
+ Math.max(0, f.percentage ?? 0),
+ )}
+ {@const isFileComplete =
+ filePercent >= 100}
<div
- class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
+ class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
>
- <span class="truncate pr-2"
- >{f.name}</span
- >
- <span
- class={isFileComplete
- ? "text-green-400"
- : "text-white/80"}
- >{filePercent.toFixed(1)}%</span
+ <div
+ class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
>
- </div>
- <div
- class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
- >
+ <span class="truncate pr-2"
+ >{f.name}</span
+ >
+ <span
+ class={isFileComplete
+ ? "text-green-400"
+ : "text-white/80"}
+ >{filePercent.toFixed(
+ 1,
+ )}%</span
+ >
+ </div>
<div
- class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
- ? 'from-green-500 to-green-400'
- : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
- style="width: {filePercent.toFixed(
- 1,
- )}%"
- ></div>
- </div>
- <div
- class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
- >
- <span
- >{formatBytes(
- f.downloadedBytes,
- )} / {formatBytes(
- f.totalBytes,
- )}</span
+ class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
>
- <span
- >{formatSpeed(f.speed)} • ETA {formatEta(
- f.etaMs,
- )}</span
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
+ ? 'from-green-500 to-green-400'
+ : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
+ style="width: {filePercent.toFixed(
+ 1,
+ )}%"
+ ></div>
+ </div>
+ <div
+ class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
>
+ <span
+ >{formatBytes(
+ f.downloadedBytes,
+ )} / {formatBytes(
+ f.totalBytes,
+ )}</span
+ >
+ <span
+ >{formatSpeed(f.speed)} • ETA {formatEta(
+ f.etaMs,
+ )}</span
+ >
+ </div>
</div>
- </div>
- {/each}
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
- <div
- class="text-xs text-blue-400 font-mono tracking-wider mt-1"
- >
- DOWNLOADING
- </div>
- {:else}
- <div
- class="text-xs {getStatusColor(
- downloadInfo.statusText,
- )} font-mono tracking-wider mt-1"
- >
- {downloadInfo.statusText}
- </div>
- {#if downloadInfo.isFailed && downloadInfo.errorMessage}
- <div
- class="text-xs text-red-400/80 font-mono mt-1 break-words"
- >
- {downloadInfo.errorMessage}
- </div>
- {:else if retryError}
- <div
- class="text-xs text-orange-400/80 font-mono mt-1 break-words"
- >
- Retrying after error: {retryError}
+ {/each}
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+ <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. This runs locally on
+ your device and needs to finish before you can
+ chat.
+ </p>
</div>
- {/if}
- {#if downloadInfo.perNodeStatus.length > 1 && (statusText.startsWith("LOADING") || statusText.startsWith("WARMING UP") || statusText === "WAITING" || statusText === "INITIALIZING")}
- <div class="mt-1.5 space-y-0.5">
- {#each downloadInfo.perNodeStatus as node}
- <div
- class="flex items-center justify-between text-[10px] font-mono"
+ {:else}
+ <div class="mt-1 space-y-1">
+ <div
+ class="text-xs {getStatusColor(
+ downloadInfo.statusText,
+ )} font-mono tracking-wider"
+ >
+ {downloadInfo.statusText}
+ </div>
+ {#if isLoading}
+ <p
+ class="text-[11px] text-white/50 leading-relaxed"
>
- <span class="text-white/60 truncate pr-2"
- >{node.nodeName}</span
- >
- <span
- class={getStatusColor(
- node.status.toUpperCase(),
- )}>{node.status}</span
- >
- </div>
- {/each}
+ Loading model into memory for fast
+ inference...
+ </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
+ class="text-xs text-red-400/80 font-mono mt-1 break-words"
+ >
+ {downloadInfo.errorMessage}
+ </div>
+ {:else if retryError}
+ <div
+ class="text-xs text-orange-400/80 font-mono mt-1 break-words"
+ >
+ Retrying after error: {retryError}
+ </div>
+ {/if}
+ {#if downloadInfo.perNodeStatus.length > 1 && (statusText.startsWith("LOADING") || statusText.startsWith("WARMING UP") || statusText === "WAITING" || statusText === "INITIALIZING")}
+ <div class="mt-1.5 space-y-0.5">
+ {#each downloadInfo.perNodeStatus as node}
+ <div
+ class="flex items-center justify-between text-[10px] font-mono"
+ >
+ <span class="text-white/60 truncate pr-2"
+ >{node.nodeName}</span
+ >
+ <span
+ class={getStatusColor(
+ node.status.toUpperCase(),
+ )}>{node.status}</span
+ >
+ </div>
+ {/each}
+ </div>
+ {/if}
{/if}
- {/if}
+ </div>
</div>
</div>
- </div>
- {/each}
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Models Panel - Scrollable -->
+ <div class="p-4 flex-1 overflow-y-auto">
+ <!-- Panel Header -->
+ <div class="flex items-center gap-2 mb-3 flex-shrink-0">
+ <div
+ class="w-2 h-2 border border-exo-yellow/60 rotate-45"
+ ></div>
+ <h3
+ class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
+ >
+ Load Model
+ </h3>
+ <div
+ class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
+ ></div>
+ <span class="text-sm text-white/70 font-mono"
+ >{models.length} models</span
+ >
</div>
- </div>
- {/if}
-
- <!-- Models Panel - Scrollable -->
- <div class="p-4 flex-1 overflow-y-auto">
- <!-- Panel Header -->
- <div class="flex items-center gap-2 mb-3 flex-shrink-0">
- <div class="w-2 h-2 border border-exo-yellow/60 rotate-45"></div>
- <h3
- class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
- >
- Launch Instance
- </h3>
- <div
- class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
- ></div>
- <span class="text-sm text-white/70 font-mono"
- >{models.length} models</span
- >
- </div>
- <!-- Model Picker Button -->
- <div class="flex-shrink-0 mb-3">
- <button
- type="button"
- onclick={() => (isModelPickerOpen = true)}
- class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-2.5 text-sm font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 relative"
- >
- {#if selectedModelId}
- {@const foundModel = models.find(
- (m) => m.id === selectedModelId,
- )}
- {#if foundModel}
- {@const sizeGB = getModelSizeGB(foundModel)}
- <span
- class="flex items-center justify-between gap-2 w-full pr-4"
- >
+ <!-- Model Picker Button -->
+ <div class="flex-shrink-0 mb-3">
+ <button
+ type="button"
+ onclick={() => (isModelPickerOpen = true)}
+ class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-2.5 text-sm font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 relative"
+ >
+ {#if selectedModelId}
+ {@const foundModel = models.find(
+ (m) => m.id === selectedModelId,
+ )}
+ {#if foundModel}
+ {@const sizeGB = getModelSizeGB(foundModel)}
<span
- class="flex items-center gap-2 text-exo-light-gray truncate"
+ class="flex items-center justify-between gap-2 w-full pr-4"
>
- <span class="truncate"
- >{foundModel.name || foundModel.id}</span
+ <span
+ class="flex items-center gap-2 text-exo-light-gray truncate"
+ >
+ <span class="truncate"
+ >{foundModel.name || foundModel.id}</span
+ >
+ </span>
+ <span class="text-white/50 text-xs flex-shrink-0"
+ >{sizeGB >= 1
+ ? sizeGB.toFixed(0)
+ : sizeGB.toFixed(1)}GB</span
>
</span>
- <span class="text-white/50 text-xs flex-shrink-0"
- >{sizeGB >= 1
- ? sizeGB.toFixed(0)
- : sizeGB.toFixed(1)}GB</span
- >
- </span>
+ {:else}
+ <span class="text-exo-light-gray">{selectedModelId}</span>
+ {/if}
{:else}
- <span class="text-exo-light-gray">{selectedModelId}</span>
+ <span class="text-white/50">— SELECT MODEL —</span>
{/if}
- {:else}
- <span class="text-white/50">— SELECT MODEL —</span>
- {/if}
- <div
- class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"
+ <div
+ class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"
+ >
+ <svg
+ class="w-4 h-4 text-exo-yellow/60"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M19 9l-7 7-7-7"
+ />
+ </svg>
+ </div>
+ </button>
+ </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-4 h-4 text-exo-yellow/60"
+ 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"
- stroke-width="2"
- d="M19 9l-7 7-7-7"
+ d="M9 5l7 7-7 7"
/>
</svg>
- </div>
- </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>
-
- <!-- 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 = "MlxJaccl";
- saveLaunchDefaults();
- }}
- class="flex items-center gap-2 py-2 px-4 text-sm 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-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
- 'MlxJaccl'
- ? 'border-exo-yellow'
- : 'border-exo-medium-gray'}"
- >
- {#if selectedInstanceType === "MlxJaccl"}
- <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
- {/if}
- </span>
- MLX RDMA
- </button>
- </div>
- </div>
+ Advanced Options
+ </button>
+
+ {#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}
- <div
- class="absolute flex flex-col items-center pointer-events-none"
- style="left: {position}%; top: 0; transform: translateX(-50%);"
- >
- <!-- Dot -->
- <span
- class="rounded-full transition-all {isSelected
- ? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]'
- : isValid
- ? 'w-4 h-4 bg-exo-light-gray/70 mt-1'
- : 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
- ></span>
- <!-- Number label below dot -->
- <span
- class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected
- ? 'text-exo-yellow font-bold'
- : isValid
- ? 'text-white/70'
- : 'text-white/30'}">{n}</span
- >
+ <!-- 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>
+ Standard
+ </button>
+ <button
+ onclick={() => {
+ selectedInstanceType = "MlxIbv";
+ 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 ===
+ '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-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
+ 'MlxIbv'
+ ? 'border-exo-yellow'
+ : 'border-exo-medium-gray'}"
+ >
+ {#if selectedInstanceType === "MlxIbv"}
+ <span
+ class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
+ ></span>
+ {/if}
+ </span>
+ RDMA (Fast)
+ </button>
+ </div>
</div>
- {/each}
- </div>
- </div>
- </div>
- <!-- Selected Model Preview -->
- <div class="space-y-3">
- {#if models.length === 0}
- <div class="text-center py-8">
- <div
- class="text-xs text-white/70 font-mono tracking-wider uppercase"
- >
- Loading models...
- </div>
- </div>
- {:else if loadingPreviews}
- <div class="text-center py-8">
- <div
- class="text-xs text-exo-yellow font-mono tracking-wider uppercase animate-pulse"
- >
- Loading preview...
- </div>
- </div>
- {:else}
- {@const selectedModel = models.find(
- (m) => m.id === selectedModelId,
- )}
- {@const allPreviews = filteredPreviews()}
- {#if selectedModel && allPreviews.length > 0}
- {@const downloadStatus = getModelDownloadStatus(
- selectedModel.id,
- )}
- {@const tags = modelTags()[selectedModel.id] || []}
- <div class="space-y-3">
- {#each allPreviews as apiPreview, i}
+ <!-- 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
- role="group"
- onmouseenter={() => {
- if (apiPreview.memory_delta_by_node) {
- hoveredPreviewNodes = new Set(
- Object.entries(apiPreview.memory_delta_by_node)
- .filter(([, delta]) => (delta ?? 0) > 0)
- .map(([nodeId]) => nodeId),
- );
- }
- }}
- onmouseleave={() => (hoveredPreviewNodes = new Set())}
+ bind:this={sliderTrackElement}
+ class="relative h-16 cursor-pointer select-none px-2 pr-6"
+ onmousedown={handleSliderMouseDown}
+ ontouchstart={handleSliderTouchStart}
>
- <ModelCard
- model={selectedModel}
- isLaunching={launchingModelId === selectedModel.id}
- {downloadStatus}
- nodes={data?.nodes ?? {}}
- sharding={apiPreview.sharding}
- runtime={apiPreview.instance_meta}
- onLaunch={() =>
- launchInstance(selectedModel.id, apiPreview)}
- {tags}
- {apiPreview}
- modelIdOverride={apiPreview.model_id}
- />
+ <!-- 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>
- {:else if selectedModel}
- <div class="text-center py-4">
- <div class="text-xs text-white/50 font-mono">
- No valid configurations for current settings
</div>
</div>
{/if}
- {/if}
- </div>
- </div>
- </aside>
- </div>
- {:else}
- <!-- CHAT STATE: Chat + Mini-Map -->
- <div class="flex-1 flex overflow-hidden">
- <!-- Chat Area -->
- <div
- class="flex-1 flex flex-col min-w-0 overflow-hidden"
- in:fade={{ duration: 300, delay: 100 }}
- >
- <div
- class="flex-1 overflow-y-auto px-8 py-6"
- bind:this={chatScrollRef}
- >
- <div class="max-w-7xl mx-auto">
- <ChatMessages scrollParent={chatScrollRef} />
- </div>
- </div>
-
- <div
- class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent"
- >
- <div class="max-w-7xl mx-auto">
- <ChatForm
- placeholder="Ask anything"
- showModelSelector={true}
- modelTasks={modelTasks()}
- modelCapabilities={modelCapabilities()}
- />
- </div>
- </div>
- </div>
-
- <!-- Right: Mini-Map Sidebar -->
- {#if minimized}
- <aside
- class="w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex flex-col flex-shrink-0 overflow-y-auto"
- in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
- >
- <!-- Topology Section - clickable to go back to main view -->
- <button
- class="p-4 border-b border-exo-medium-gray/30 w-full text-left cursor-pointer hover:bg-exo-medium-gray/10 transition-colors"
- onclick={handleGoHome}
- title="Click to return to main topology view"
- >
- <div class="flex items-center justify-between mb-3">
- <div
- class="text-xs text-exo-yellow tracking-[0.2em] uppercase flex items-center gap-2"
- >
- <span
- class="w-1.5 h-1.5 bg-exo-yellow rounded-full status-pulse"
- ></span>
- TOPOLOGY
- </div>
- <span class="text-xs text-white/70 tabular-nums"
- >{nodeCount} {nodeCount === 1 ? "NODE" : "NODES"}</span
- >
- </div>
-
- <div
- class="relative aspect-square bg-exo-dark-gray rounded-lg overflow-hidden"
- >
- <TopologyGraph
- highlightedNodes={highlightedNodes()}
- filteredNodes={nodeFilter}
- onNodeClick={togglePreviewNodeFilter}
- />
-
- {@render clusterWarningsCompact()}
</div>
- </button>
- <!-- Instances Section (only shown when instances exist) -->
- {#if instanceCount > 0}
- <div class="p-4 flex-1">
- <!-- Panel Header -->
- <div class="flex items-center gap-2 mb-4">
- <div
- class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
- ></div>
- <h3
- class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
- >
- Instances
- </h3>
- <div
- class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
- ></div>
- </div>
- <div
- class="space-y-3 max-h-72 xl:max-h-96 overflow-y-auto overflow-x-hidden py-px pr-1"
- >
- {#each unifiedDisplayItems as item (item.id)}
- {@const id = item.id}
- {@const instance = item.instance}
- {@const downloadInfo = instance
- ? getInstanceDownloadStatus(
- item.instanceId ?? id,
- instance,
- )
- : getMetaInstancePlacingStatus(id)}
- {@const metaData = item.isMetaInstance
- ? metaInstancesData[id]
- : null}
- {@const retryError =
- metaData?.lastFailureError && !downloadInfo.isFailed
- ? metaData.consecutiveFailures > 0
- ? `(${((metaData.consecutiveFailures - 1) % 3) + 1}/3) ${metaData.lastFailureError}`
- : metaData.lastFailureError
- : null}
- {@const statusText = downloadInfo.statusText}
- {@const isDownloading = downloadInfo.isDownloading}
- {@const isFailed =
- statusText === "FAILED" ||
- statusText === "PLACEMENT FAILED"}
- {@const isLoading =
- statusText.startsWith("LOADING") ||
- statusText.startsWith("WARMING UP") ||
- statusText === "WAITING" ||
- statusText === "PLACING" ||
- statusText.startsWith("RETRYING")}
- {@const isReady =
- statusText === "READY" || statusText === "LOADED"}
- {@const isRunning = statusText === "RUNNING"}
- <!-- Instance Card -->
- {@const instanceModelId = item.modelId}
- {@const instanceInfo = instance
- ? getInstanceInfo(instance)
- : {
- instanceType:
- item.instanceMeta === "MlxRing"
- ? "MLX Ring"
- : item.instanceMeta === "MlxJaccl"
- ? "MLX RDMA"
- : "Unknown",
- sharding: item.sharding ?? "Unknown",
- nodeNames: [] as string[],
- nodeIds: [] as string[],
- nodeCount: 0,
- }}
- {@const instanceConnections = instance
- ? getInstanceConnections(instance)
- : []}
+ <!-- Selected Model Preview -->
+ <div class="space-y-3">
+ {#if models.length === 0}
+ <div class="text-center py-8">
+ <div
+ class="text-xs text-white/70 font-mono tracking-wider uppercase"
+ >
+ Loading models...
+ </div>
+ </div>
+ {:else if loadingPreviews}
+ <div class="text-center py-8">
+ <div
+ class="text-xs text-exo-yellow font-mono tracking-wider uppercase animate-pulse"
+ >
+ Loading preview...
+ </div>
+ </div>
+ {:else}
+ {@const selectedModel = models.find(
+ (m) => m.id === selectedModelId,
+ )}
+ {@const allPreviews = filteredPreviews()}
+ {#if selectedModel && allPreviews.length > 0}
+ {@const downloadStatus = getModelDownloadStatus(
+ selectedModel.id,
+ )}
+ {@const tags = modelTags()[selectedModel.id] || []}
+ <div class="space-y-3">
+ {#each allPreviews as apiPreview, i}
+ <div
+ role="group"
+ onmouseenter={() => {
+ if (apiPreview.memory_delta_by_node) {
+ hoveredPreviewNodes = new Set(
+ Object.entries(apiPreview.memory_delta_by_node)
+ .filter(([, delta]) => (delta ?? 0) > 0)
+ .map(([nodeId]) => nodeId),
+ );
+ }
+ }}
+ onmouseleave={() => (hoveredPreviewNodes = new Set())}
+ >
+ <ModelCard
+ model={selectedModel}
+ isLaunching={launchingModelId === selectedModel.id}
+ {downloadStatus}
+ nodes={data?.nodes ?? {}}
+ sharding={apiPreview.sharding}
+ runtime={apiPreview.instance_meta}
+ onLaunch={() =>
+ launchInstance(selectedModel.id, apiPreview)}
+ {tags}
+ {apiPreview}
+ modelIdOverride={apiPreview.model_id}
+ />
+ </div>
+ {/each}
+ </div>
+ {:else if selectedModel}
+ <div class="text-center py-4">
+ <div class="text-xs text-white/50 font-mono">
+ No valid configurations for current settings
+ </div>
+ </div>
+ {/if}
+ {/if}
+ </div>
+ </div>
+ </aside>
+ </div>
+ {:else}
+ <!-- CHAT STATE: Chat + Mini-Map -->
+ <div class="flex-1 flex overflow-hidden">
+ <!-- Chat Area -->
+ <div
+ class="flex-1 flex flex-col min-w-0 overflow-hidden"
+ in:fade={{ duration: 300, delay: 100 }}
+ >
+ <div
+ class="flex-1 overflow-y-auto px-8 py-6"
+ bind:this={chatScrollRef}
+ role="log"
+ aria-live="polite"
+ aria-label="Chat messages"
+ >
+ <div class="max-w-7xl mx-auto">
+ <ChatMessages scrollParent={chatScrollRef} />
+ </div>
+ </div>
+
+ <div
+ class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent"
+ >
+ <div class="max-w-7xl mx-auto">
+ <ChatForm
+ placeholder="Ask anything"
+ showModelSelector={true}
+ modelTasks={modelTasks()}
+ modelCapabilities={modelCapabilities()}
+ />
+ </div>
+ </div>
+ </div>
+
+ <!-- Right: Mini-Map Sidebar -->
+ {#if minimized}
+ <aside
+ class="w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex flex-col flex-shrink-0 overflow-y-auto"
+ in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
+ aria-label="Cluster topology"
+ >
+ <!-- Topology Section - clickable to go back to main view -->
+ <button
+ class="p-4 border-b border-exo-medium-gray/30 w-full text-left cursor-pointer hover:bg-exo-medium-gray/10 transition-colors"
+ onclick={handleGoHome}
+ title="Click to return to main topology view"
+ >
+ <div class="flex items-center justify-between mb-3">
+ <div
+ class="text-xs text-exo-yellow tracking-[0.2em] uppercase flex items-center gap-2"
+ >
+ <span
+ class="w-1.5 h-1.5 bg-exo-yellow rounded-full status-pulse"
+ ></span>
+ TOPOLOGY
+ </div>
+ <span class="text-xs text-white/70 tabular-nums"
+ >{nodeCount} {nodeCount === 1 ? "NODE" : "NODES"}</span
+ >
+ </div>
+
+ <div
+ class="relative aspect-square bg-exo-dark-gray rounded-lg overflow-hidden"
+ >
+ <TopologyGraph
+ highlightedNodes={highlightedNodes()}
+ filteredNodes={nodeFilter}
+ onNodeClick={togglePreviewNodeFilter}
+ />
+
+ {@render clusterWarningsCompact()}
+ </div>
+ </button>
+
+ <!-- Instances Section (only shown when instances exist) -->
+ {#if instanceCount > 0}
+ <div class="p-4 flex-1">
+ <!-- Panel Header -->
+ <div class="flex items-center gap-2 mb-4">
<div
- class="relative group cursor-pointer"
- role="button"
- tabindex="0"
- onmouseenter={() =>
- (hoveredInstanceId = item.instanceId ?? id)}
- onmouseleave={() => (hoveredInstanceId = null)}
- onclick={() => {
- if (
- instanceModelId &&
- instanceModelId !== "Unknown" &&
- instanceModelId !== "Unknown Model"
- ) {
- setSelectedChatModel(instanceModelId);
- }
- }}
- onkeydown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
+ class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
+ ></div>
+ <h3
+ class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
+ >
+ Instances
+ </h3>
+ <div
+ class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
+ ></div>
+ </div>
+ <div
+ class="space-y-3 max-h-72 xl:max-h-96 overflow-y-auto overflow-x-hidden py-px pr-1"
+ >
+ {#each Object.entries(instanceData) as [id, instance]}
+ {@const downloadInfo = getInstanceDownloadStatus(
+ id,
+ instance,
+ )}
+ {@const statusText = downloadInfo.statusText}
+ {@const isDownloading = downloadInfo.isDownloading}
+ {@const isFailed = statusText === "FAILED"}
+ {@const isLoading =
+ statusText === "LOADING" ||
+ statusText === "WARMING UP" ||
+ statusText === "WAITING"}
+ {@const isReady =
+ statusText === "READY" || statusText === "LOADED"}
+ {@const isRunning = statusText === "RUNNING"}
+ <!-- Instance Card -->
+ {@const instanceModelId = getInstanceModelId(instance)}
+ {@const instanceInfo = getInstanceInfo(instance)}
+ {@const instanceConnections =
+ getInstanceConnections(instance)}
+ <div
+ class="relative group cursor-pointer"
+ role="button"
+ tabindex="0"
+ onmouseenter={() => (hoveredInstanceId = id)}
+ onmouseleave={() => (hoveredInstanceId = null)}
+ onclick={() => {
if (
instanceModelId &&
instanceModelId !== "Unknown" &&
@@ -3657,439 +4970,466 @@
) {
setSelectedChatModel(instanceModelId);
}
- }
- }}
- >
- <!-- Corner accents -->
- <div
- class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
- <div
- class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
- <div
- class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
- <div
- class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
-
- <div
- class="bg-exo-dark-gray/60 border border-l-2 {isDownloading
- ? 'border-blue-500/30 border-l-blue-400'
- : isFailed
- ? 'border-red-500/30 border-l-red-400'
- : isLoading
- ? 'border-exo-yellow/30 border-l-yellow-400'
- : isReady
- ? 'border-green-500/30 border-l-green-400'
- : 'border-teal-500/30 border-l-teal-400'} p-3"
+ }}
+ onkeydown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ if (
+ instanceModelId &&
+ instanceModelId !== "Unknown" &&
+ instanceModelId !== "Unknown Model"
+ ) {
+ setSelectedChatModel(instanceModelId);
+ }
+ }
+ }}
>
- <div class="flex justify-between items-start mb-2 pl-2">
- <div class="flex items-center gap-2">
- <div
- class="w-1.5 h-1.5 {isDownloading
- ? 'bg-blue-400 animate-pulse'
- : isFailed
- ? 'bg-red-400'
- : isLoading
- ? 'bg-yellow-400 animate-pulse'
- : isReady
- ? 'bg-green-400'
- : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"
- ></div>
- <span
- class="text-exo-light-gray font-mono text-sm tracking-wider"
- >{id.slice(0, 8).toUpperCase()}</span
- >
- </div>
- <button
- onclick={() =>
- item.isMetaInstance
- ? deleteMetaInstance(id)
- : deleteInstance(id)}
- class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
- >
- DELETE
- </button>
- </div>
- <div class="pl-2">
+ <!-- Corner accents -->
+ <div
+ class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading
+ ? 'border-blue-500/50'
+ : isFailed
+ ? 'border-red-500/50'
+ : isLoading
+ ? 'border-yellow-500/50'
+ : isReady
+ ? 'border-green-500/50'
+ : 'border-teal-500/50'}"
+ ></div>
+ <div
+ class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading
+ ? 'border-blue-500/50'
+ : isFailed
+ ? 'border-red-500/50'
+ : isLoading
+ ? 'border-yellow-500/50'
+ : isReady
+ ? 'border-green-500/50'
+ : 'border-teal-500/50'}"
+ ></div>
+ <div
+ class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading
+ ? 'border-blue-500/50'
+ : isFailed
+ ? 'border-red-500/50'
+ : isLoading
+ ? 'border-yellow-500/50'
+ : isReady
+ ? 'border-green-500/50'
+ : 'border-teal-500/50'}"
+ ></div>
+ <div
+ class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading
+ ? 'border-blue-500/50'
+ : isFailed
+ ? 'border-red-500/50'
+ : isLoading
+ ? 'border-yellow-500/50'
+ : isReady
+ ? 'border-green-500/50'
+ : 'border-teal-500/50'}"
+ ></div>
+
+ <div
+ class="bg-exo-dark-gray/60 border border-l-2 {isDownloading
+ ? 'border-blue-500/30 border-l-blue-400'
+ : isFailed
+ ? 'border-red-500/30 border-l-red-400'
+ : isLoading
+ ? 'border-exo-yellow/30 border-l-yellow-400'
+ : isReady
+ ? 'border-green-500/30 border-l-green-400'
+ : 'border-teal-500/30 border-l-teal-400'} p-3"
+ >
<div
- class="text-exo-yellow text-xs font-mono tracking-wide truncate"
+ class="flex justify-between items-start mb-2 pl-2"
>
- {instanceModelId}
- </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">
+ <div
+ class="w-1.5 h-1.5 {isDownloading
+ ? 'bg-blue-400 animate-pulse'
+ : isFailed
+ ? 'bg-red-400'
+ : isLoading
+ ? 'bg-yellow-400 animate-pulse'
+ : isReady
+ ? 'bg-green-400'
+ : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"
+ ></div>
+ <span
+ class="text-exo-light-gray font-mono text-sm tracking-wider"
+ >{id.slice(0, 8).toUpperCase()}</span
+ >
+ </div>
+ <button
+ onclick={() => deleteInstance(id)}
+ class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
>
+ DELETE
+ </button>
</div>
- {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
- <a
- class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
- href={`https://huggingface.co/${instanceModelId}`}
- target="_blank"
- rel="noreferrer noopener"
- aria-label="View model on Hugging Face"
+ <div class="pl-2">
+ <div
+ class="text-exo-yellow text-xs font-mono tracking-wide truncate"
>
- <span>Hugging Face</span>
- <svg
- class="w-3.5 h-3.5"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- >
- <path d="M14 3h7v7" />
- <path d="M10 14l11-11" />
- <path
- d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
- />
- </svg>
- </a>
- {/if}
- {#if instanceInfo.nodeNames.length > 0}
- <div class="text-white/60 text-xs font-mono">
- {instanceInfo.nodeNames.join(", ")}
+ {getInstanceModelId(instance)}
</div>
- {/if}
- {#if debugEnabled && instanceConnections.length > 0}
- <div class="mt-2 space-y-1">
- {#each instanceConnections as conn}
- <div
- class="text-[11px] leading-snug font-mono text-white/70"
- >
- <span
- >{conn.from} -> {conn.to}: {conn.ip}</span
- >
- <span
- class={conn.missingIface
- ? "text-red-400"
- : "text-white/60"}
- >
- ({conn.ifaceLabel})</span
- >
- </div>
- {/each}
+ <div
+ 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}
-
- <!-- Download Progress -->
- {#if downloadInfo.isDownloading && downloadInfo.progress}
- <div class="mt-2 space-y-1">
- <div
- class="flex justify-between text-xs font-mono"
+ {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
+ <a
+ class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
+ href={`https://huggingface.co/${instanceModelId}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ aria-label="View model on Hugging Face"
>
- <span class="text-blue-400"
- >{downloadInfo.progress.percentage.toFixed(
- 1,
- )}%</span
- >
- <span class="text-exo-light-gray"
- >{formatBytes(
- downloadInfo.progress.downloadedBytes,
- )}/{formatBytes(
- downloadInfo.progress.totalBytes,
- )}</span
+ <span>Hugging Face</span>
+ <svg
+ class="w-3.5 h-3.5"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
>
+ <path d="M14 3h7v7" />
+ <path d="M10 14l11-11" />
+ <path
+ d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
+ />
+ </svg>
+ </a>
+ {/if}
+ {#if instanceInfo.nodeNames.length > 0}
+ <div class="text-white/60 text-xs font-mono">
+ {instanceInfo.nodeNames.join(", ")}
</div>
- <div
- class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
- >
- <div
- class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
- style="width: {downloadInfo.progress
- .percentage}%"
- ></div>
+ {/if}
+ {#if debugEnabled && instanceConnections.length > 0}
+ <div class="mt-2 space-y-1">
+ {#each instanceConnections as conn}
+ <div
+ class="text-[11px] leading-snug font-mono text-white/70"
+ >
+ <span
+ >{conn.from} -> {conn.to}: {conn.ip}</span
+ >
+ <span
+ class={conn.missingIface
+ ? "text-red-400"
+ : "text-white/60"}
+ >
+ ({conn.ifaceLabel})</span
+ >
+ </div>
+ {/each}
</div>
- <div
- class="flex justify-between text-xs font-mono text-exo-light-gray"
- >
- <span
- >{formatSpeed(
- downloadInfo.progress.speed,
- )}</span
+ {/if}
+
+ <!-- Download Progress -->
+ {#if downloadInfo.isDownloading && downloadInfo.progress}
+ <div class="mt-2 space-y-1">
+ <div
+ class="flex justify-between text-xs font-mono"
>
- <span
- >ETA: {formatEta(
- downloadInfo.progress.etaMs,
- )}</span
+ <span class="text-blue-400"
+ >{downloadInfo.progress.percentage.toFixed(
+ 1,
+ )}%</span
+ >
+ <span class="text-exo-light-gray"
+ >{formatBytes(
+ downloadInfo.progress.downloadedBytes,
+ )}/{formatBytes(
+ downloadInfo.progress.totalBytes,
+ )}</span
+ >
+ </div>
+ <div
+ class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
>
- <span
- >{downloadInfo.progress
- .completedFiles}/{downloadInfo.progress
- .totalFiles} files</span
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+ style="width: {downloadInfo.progress
+ .percentage}%"
+ ></div>
+ </div>
+ <div
+ class="flex justify-between text-xs font-mono text-exo-light-gray"
>
+ <span
+ >{formatSpeed(
+ downloadInfo.progress.speed,
+ )}</span
+ >
+ <span
+ >ETA: {formatEta(
+ downloadInfo.progress.etaMs,
+ )}</span
+ >
+ <span
+ >{downloadInfo.progress
+ .completedFiles}/{downloadInfo.progress
+ .totalFiles} files</span
+ >
+ </div>
</div>
- </div>
- {#if downloadInfo.perNode.length > 0}
- <div
- class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
- >
- {#each downloadInfo.perNode as nodeProg}
- {@const nodePercent = Math.min(
- 100,
- Math.max(0, nodeProg.progress.percentage),
- )}
- {@const isExpanded =
- instanceDownloadExpandedNodes.has(
- nodeProg.nodeId,
+ {#if downloadInfo.perNode.length > 0}
+ <div
+ class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
+ >
+ {#each downloadInfo.perNode as nodeProg}
+ {@const nodePercent = Math.min(
+ 100,
+ Math.max(0, nodeProg.progress.percentage),
)}
- <div
- class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
- >
- <button
- type="button"
- class="w-full text-left space-y-1.5"
- onclick={() =>
- toggleInstanceDownloadDetails(
- nodeProg.nodeId,
- )}
+ {@const isExpanded =
+ instanceDownloadExpandedNodes.has(
+ nodeProg.nodeId,
+ )}
+ <div
+ class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
>
- <div
- class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+ <button
+ type="button"
+ class="w-full text-left space-y-1.5"
+ onclick={() =>
+ toggleInstanceDownloadDetails(
+ nodeProg.nodeId,
+ )}
>
- <span
- class="text-white/80 truncate pr-2"
- >{nodeProg.nodeName}</span
- >
- <span
- class="flex items-center gap-1 text-blue-300"
+ <div
+ class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
>
- {nodePercent.toFixed(1)}%
- <svg
- class="w-3 h-3 text-exo-light-gray"
- viewBox="0 0 20 20"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
+ <span
+ class="text-white/80 truncate pr-2"
+ >{nodeProg.nodeName}</span
>
- <path
- d="M6 8l4 4 4-4"
- class={isExpanded
- ? "transform rotate-180 origin-center transition-transform duration-150"
- : "transition-transform duration-150"}
- ></path>
- </svg>
- </span>
- </div>
- <div
- class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
- >
+ <span
+ class="flex items-center gap-1 text-blue-300"
+ >
+ {nodePercent.toFixed(1)}%
+ <svg
+ class="w-3 h-3 text-exo-light-gray"
+ viewBox="0 0 20 20"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ d="M6 8l4 4 4-4"
+ class={isExpanded
+ ? "transform rotate-180 origin-center transition-transform duration-150"
+ : "transition-transform duration-150"}
+ ></path>
+ </svg>
+ </span>
+ </div>
<div
- class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
- style="width: {nodePercent.toFixed(
- 1,
- )}%"
- ></div>
- </div>
- <div
- class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
- >
- <span
- >{formatBytes(
- nodeProg.progress.downloadedBytes,
- )} / {formatBytes(
- nodeProg.progress.totalBytes,
- )}</span
+ class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
>
- <span
- >{formatSpeed(
- nodeProg.progress.speed,
- )} • ETA {formatEta(
- nodeProg.progress.etaMs,
- )}</span
- >
- </div>
- </button>
-
- {#if isExpanded}
- <div class="mt-2 space-y-1.5">
- {#if nodeProg.progress.files.length === 0}
<div
- class="text-[11px] font-mono text-exo-light-gray/70"
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+ style="width: {nodePercent.toFixed(
+ 1,
+ )}%"
+ ></div>
+ </div>
+ <div
+ class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+ >
+ <span
+ >{formatBytes(
+ nodeProg.progress.downloadedBytes,
+ )} / {formatBytes(
+ nodeProg.progress.totalBytes,
+ )}</span
>
- No file details reported.
- </div>
- {:else}
- {#each nodeProg.progress.files as f}
- {@const filePercent = Math.min(
- 100,
- Math.max(0, f.percentage ?? 0),
- )}
- {@const isFileComplete =
- filePercent >= 100}
+ <span
+ >{formatSpeed(
+ nodeProg.progress.speed,
+ )} • ETA {formatEta(
+ nodeProg.progress.etaMs,
+ )}</span
+ >
+ </div>
+ </button>
+
+ {#if isExpanded}
+ <div class="mt-2 space-y-1.5">
+ {#if nodeProg.progress.files.length === 0}
<div
- class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
+ class="text-[11px] font-mono text-exo-light-gray/70"
>
+ No file details reported.
+ </div>
+ {:else}
+ {#each nodeProg.progress.files as f}
+ {@const filePercent = Math.min(
+ 100,
+ Math.max(0, f.percentage ?? 0),
+ )}
+ {@const isFileComplete =
+ filePercent >= 100}
<div
- class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
+ class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
>
- <span class="truncate pr-2"
- >{f.name}</span
- >
- <span
- class={isFileComplete
- ? "text-green-400"
- : "text-white/80"}
- >{filePercent.toFixed(
- 1,
- )}%</span
+ <div
+ class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
>
- </div>
- <div
- class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
- >
+ <span class="truncate pr-2"
+ >{f.name}</span
+ >
+ <span
+ class={isFileComplete
+ ? "text-green-400"
+ : "text-white/80"}
+ >{filePercent.toFixed(
+ 1,
+ )}%</span
+ >
+ </div>
<div
- class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
- ? 'from-green-500 to-green-400'
- : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
- style="width: {filePercent.toFixed(
- 1,
- )}%"
- ></div>
- </div>
- <div
- class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
- >
- <span
- >{formatBytes(
- f.downloadedBytes,
- )} / {formatBytes(
- f.totalBytes,
- )}</span
+ class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
>
- <span
- >{formatSpeed(f.speed)} • ETA {formatEta(
- f.etaMs,
- )}</span
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
+ ? 'from-green-500 to-green-400'
+ : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
+ style="width: {filePercent.toFixed(
+ 1,
+ )}%"
+ ></div>
+ </div>
+ <div
+ class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
>
+ <span
+ >{formatBytes(
+ f.downloadedBytes,
+ )} / {formatBytes(
+ f.totalBytes,
+ )}</span
+ >
+ <span
+ >{formatSpeed(f.speed)} • ETA
+ {formatEta(f.etaMs)}</span
+ >
+ </div>
</div>
- </div>
- {/each}
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
- <div
- class="text-xs text-blue-400 font-mono tracking-wider mt-1"
- >
- DOWNLOADING
- </div>
- {:else}
- <div
- class="text-xs {getStatusColor(
- downloadInfo.statusText,
- )} font-mono tracking-wider mt-1"
- >
- {downloadInfo.statusText}
- </div>
- {#if downloadInfo.isFailed && downloadInfo.errorMessage}
- <div
- class="text-xs text-red-400/80 font-mono mt-1 break-words"
- >
- {downloadInfo.errorMessage}
- </div>
- {:else if retryError}
- <div
- class="text-xs text-orange-400/80 font-mono mt-1 break-words"
- >
- Retrying after error: {retryError}
+ {/each}
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+ <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. This runs locally on
+ your device and needs to finish before you can
+ chat.
+ </p>
</div>
- {/if}
- {#if downloadInfo.perNodeStatus.length > 1 && (statusText.startsWith("LOADING") || statusText.startsWith("WARMING UP") || statusText === "WAITING" || statusText === "INITIALIZING")}
- <div class="mt-1.5 space-y-0.5">
- {#each downloadInfo.perNodeStatus as node}
- <div
- class="flex items-center justify-between text-[10px] font-mono"
+ {:else}
+ <div class="mt-1 space-y-1">
+ <div
+ class="text-xs {getStatusColor(
+ downloadInfo.statusText,
+ )} font-mono tracking-wider"
+ >
+ {downloadInfo.statusText}
+ </div>
+ {#if isLoading}
+ <p
+ class="text-[11px] text-white/50 leading-relaxed"
>
- <span class="text-white/60 truncate pr-2"
- >{node.nodeName}</span
- >
- <span
- class={getStatusColor(
- node.status.toUpperCase(),
- )}>{node.status}</span
- >
- </div>
- {/each}
+ Loading model into memory for fast
+ inference...
+ </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
+ class="text-xs text-red-400/80 font-mono mt-1 break-words"
+ >
+ {downloadInfo.errorMessage}
+ </div>
+ {/if}
{/if}
- {/if}
+ </div>
</div>
</div>
- </div>
- {/each}
+ {/each}
+ </div>
</div>
- </div>
- {/if}
- </aside>
- {/if}
- </div>
- {/if}
- </main>
+ {/if}
+ </aside>
+ {/if}
+ </div>
+ {/if}
+ </main>
+ {/if}
</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/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()
← facf2d4d Use custom fork that resolves GPU locks (#1489)
·
back to Exo
·
Revert "feat: better onboarding UX for new users (#1479)" c8997217 →