← back to Exo
Revert "feat: better onboarding UX for new users (#1479)"
c8997217cfab22835d3bea407a0709d3f710318b · 2026-02-17 17:56:26 +0000 · Evan Quiney
This reverts commit 490d2e46ba469834215f318a358b37deb5e0e9a5.
Files touched
M .github/workflows/build-app.ymlM app/EXO/EXO/ContentView.swiftM app/EXO/EXO/EXOApp.swiftM app/EXO/EXO/ExoProcessController.swiftD app/EXO/EXO/Views/FirstLaunchPopout.swiftD app/EXO/EXO/Views/SettingsView.swiftD 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.svelteD dashboard/src/lib/components/ConnectionBanner.svelteM dashboard/src/lib/components/HeaderNav.svelteM dashboard/src/lib/components/ModelCard.svelteM dashboard/src/lib/components/ModelPickerModal.svelteD dashboard/src/lib/components/ToastContainer.svelteM dashboard/src/lib/stores/app.svelte.tsD dashboard/src/lib/stores/toast.svelte.tsM dashboard/src/routes/+layout.svelteM dashboard/src/routes/+page.svelteD packaging/dmg/background.pngD packaging/dmg/create-dmg.shD packaging/dmg/generate-background.pyM src/exo/utils/banner.py
Diff
commit c8997217cfab22835d3bea407a0709d3f710318b
Author: Evan Quiney <evanev7@gmail.com>
Date: Tue Feb 17 17:56:26 2026 +0000
Revert "feat: better onboarding UX for new users (#1479)"
This reverts commit 490d2e46ba469834215f318a358b37deb5e0e9a5.
---
.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 | 3572 ++++++--------------
packaging/dmg/background.png | Bin 7010 -> 0 bytes
packaging/dmg/create-dmg.sh | 112 -
packaging/dmg/generate-background.py | 91 -
src/exo/utils/banner.py | 30 -
24 files changed, 1582 insertions(+), 3966 deletions(-)
diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml
index 861f4697..d20bfc12 100644
--- a/.github/workflows/build-app.yml
+++ b/.github/workflows/build-app.yml
@@ -303,8 +303,11 @@ 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"
- bash "$GITHUB_WORKSPACE/packaging/dmg/create-dmg.sh" EXO.app "$DMG_NAME" "EXO"
+ hdiutil create -volname "EXO" -srcfolder dmg-root -ov -format UDZO "$DMG_NAME"
/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 ca09fe2f..688fba5b 100644
--- a/app/EXO/EXO/ContentView.swift
+++ b/app/EXO/EXO/ContentView.swift
@@ -15,12 +15,18 @@ struct ContentView: View {
@EnvironmentObject private var localNetworkChecker: LocalNetworkChecker
@EnvironmentObject private var updater: SparkleUpdater
@EnvironmentObject private var thunderboltBridgeService: ThunderboltBridgeService
- @EnvironmentObject private var settingsWindowController: SettingsWindowController
@State private var focusedNode: NodeViewModel?
@State private var deletingInstanceIDs: Set<String> = []
@State private var showAllNodes = false
@State private var showAllInstances = false
- @State private var baseURLCopied = false
+ @State private var showAdvanced = false
+ @State private var showDebugInfo = false
+ @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
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -252,79 +258,139 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 0) {
if controller.status != .stopped {
dashboardButton
- baseURLRow
Divider()
.padding(.vertical, 8)
} else {
Divider()
.padding(.vertical, 4)
}
- HoverButton(
- title: "Settings",
- tint: .primary,
- trailingSystemImage: "gear"
- ) {
- settingsWindowController.open(
- controller: controller,
- updater: updater,
- networkStatusService: networkStatusService,
- thunderboltBridgeService: thunderboltBridgeService,
- stateService: stateService
- )
- }
- HoverButton(
- title: "Check for Updates",
- tint: .primary,
- trailingSystemImage: "arrow.triangle.2.circlepath"
- ) {
- updater.checkForUpdates()
- }
- .padding(.bottom, 8)
- HoverButton(title: "Quit", tint: .secondary) {
+ advancedSection
+ .padding(.bottom, 8)
+ controlButton(title: "Quit", tint: .secondary) {
controller.stop()
NSApplication.shared.terminate(nil)
}
}
}
- private var dashboardButton: some View {
- HoverButton(
- title: "Web Dashboard",
- tint: .primary,
- trailingSystemImage: "arrow.up.right"
- ) {
- guard let url = URL(string: "http://localhost:52415/") else { return }
- NSWorkspace.shared.open(url)
+ private var advancedSection: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Text("Advanced")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ collapseButton(isExpanded: $showAdvanced)
+ }
+ .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)
+ }
}
+ .animation(.easeInOut(duration: 0.25), value: showAdvanced)
}
- 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")
+ 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 {
+ guard let url = URL(string: "http://localhost:52415/") else { return }
+ NSWorkspace.shared.open(url)
+ } label: {
+ HStack {
+ Image(systemName: "arrow.up.right.square")
.imageScale(.small)
- .foregroundColor(baseURLCopied ? .green : .secondary)
- .contentTransition(.symbolEffect(.replace))
+ Text("Dashboard")
+ .fontWeight(.medium)
+ Spacer()
}
- .buttonStyle(.plain)
- .help("Copy API base URL")
+ .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))
+ )
}
- .padding(.vertical, 4)
- .padding(.horizontal, 8)
+ .buttonStyle(.plain)
+ .padding(.bottom, 4)
}
private func collapseButton(isExpanded: Binding<Bool>) -> some View {
@@ -379,6 +445,207 @@ 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: {
@@ -419,6 +686,101 @@ 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 bba26e24..3aff58c3 100644
--- a/app/EXO/EXO/EXOApp.swift
+++ b/app/EXO/EXO/EXOApp.swift
@@ -21,9 +21,7 @@ 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() {
@@ -45,13 +43,12 @@ 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: 5)
+ controller.scheduleLaunch(after: 15)
service.startPolling()
networkStatus.startPolling()
}
@@ -65,19 +62,8 @@ 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 2350c118..7566674b 100644
--- a/app/EXO/EXO/ExoProcessController.swift
+++ b/app/EXO/EXO/ExoProcessController.swift
@@ -5,7 +5,6 @@ import Foundation
private let customNamespaceKey = "EXOCustomNamespace"
private let hfTokenKey = "EXOHFToken"
private let enableImageModelsKey = "EXOEnableImageModels"
-private let onboardingCompletedKey = "EXOOnboardingCompleted"
@MainActor
final class ExoProcessController: ObservableObject {
@@ -61,9 +60,6 @@ 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>?
@@ -117,11 +113,6 @@ 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")
@@ -173,17 +164,6 @@ 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
deleted file mode 100644
index 1a10b3ac..00000000
--- a/app/EXO/EXO/Views/FirstLaunchPopout.swift
+++ /dev/null
@@ -1,192 +0,0 @@
-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
deleted file mode 100644
index ed221c89..00000000
--- a/app/EXO/EXO/Views/SettingsView.swift
+++ /dev/null
@@ -1,478 +0,0 @@
-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
deleted file mode 100644
index 98517f92..00000000
--- a/app/EXO/EXO/Views/SettingsWindowController.swift
+++ /dev/null
@@ -1,47 +0,0 @@
-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 3e951163..fc532578 100644
--- a/dashboard/src/app.css
+++ b/dashboard/src/app.css
@@ -202,15 +202,6 @@
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;
@@ -329,31 +320,3 @@ 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 1bc554e8..2eb7aa40 100644
--- a/dashboard/src/lib/components/ChatForm.svelte
+++ b/dashboard/src/lib/components/ChatForm.svelte
@@ -28,7 +28,6 @@
showModelSelector?: boolean;
modelTasks?: Record<string, string[]>;
modelCapabilities?: Record<string, string[]>;
- onSend?: () => void;
}
let {
@@ -39,7 +38,6 @@
showModelSelector = false,
modelTasks = {},
modelCapabilities = {},
- onSend,
}: Props = $props();
let message = $state("");
@@ -307,8 +305,6 @@
);
}
- 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 7c076459..ba5322a7 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-xs text-white/30 tracking-wider mt-1.5 font-mono">
- Type a message below · Shift+Enter for newline
+ <p class="text-sm sm:text-xs text-exo-light-gray tracking-wider mt-1">
+ ENTER A QUERY TO BEGIN
</p>
</div>
{/if}
@@ -818,7 +818,6 @@
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 e7dbe908..6a822ddf 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-xs text-exo-light-gray font-mono tracking-wider uppercase"
+ class="text-sm text-white/70 font-mono tracking-wider uppercase"
>
{searchQuery ? "SEARCH RESULTS" : "CONVERSATIONS"}
</span>
@@ -372,37 +372,39 @@
onkeydown={(e) =>
e.key === "Enter" &&
handleSelectConversation(conversation.id)}
- class="group w-full flex items-center justify-between p-2.5 rounded-lg mb-1 transition-all text-left cursor-pointer
+ class="group w-full flex items-center justify-between p-2 rounded mb-1 transition-all text-left cursor-pointer
{activeId === conversation.id
- ? 'bg-exo-yellow/5 border border-exo-yellow/30'
- : 'hover:bg-white/[0.03] hover:border-white/10 border border-transparent'}"
+ ? 'bg-transparent border border-exo-yellow/30'
+ : 'hover:border-exo-yellow/20 border border-transparent'}"
>
<div class="flex-1 min-w-0 pr-2">
<div
- class="text-sm font-medium truncate {activeId ===
- conversation.id
+ class="text-sm truncate {activeId === conversation.id
? 'text-exo-yellow'
- : 'text-white'}"
+ : 'text-white/90'}"
>
{conversation.name}
</div>
- <div class="text-xs text-white/60 mt-0.5">
+ <div class="text-sm text-white/50 mt-0.5">
{formatDate(conversation.updatedAt)}
</div>
- <div class="text-xs text-exo-light-gray truncate">
+ <div class="text-sm text-white/70 truncate">
{info.modelLabel}
</div>
+ <div class="text-xs text-white/60 font-mono">
+ Strategy: <span class="text-white/80"
+ >{info.strategyLabel}</span
+ >
+ </div>
{#if stats}
- <div class="text-xs text-white/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 class="text-xs text-white/60 font-mono mt-1">
+ {#if stats.ttftMs}<span class="text-white/40">TTFT</span>
+ {stats.ttftMs.toFixed(
+ 0,
+ )}ms{/if}{#if stats.ttftMs && stats.tps}<span
+ class="text-white/30 mx-1.5">•</span
+ >{/if}{#if stats.tps}{stats.tps.toFixed(1)}
+ <span class="text-white/40">tok/s</span>{/if}
</div>
{/if}
</div>
diff --git a/dashboard/src/lib/components/ConnectionBanner.svelte b/dashboard/src/lib/components/ConnectionBanner.svelte
deleted file mode 100644
index 3339cf24..00000000
--- a/dashboard/src/lib/components/ConnectionBanner.svelte
+++ /dev/null
@@ -1,20 +0,0 @@
-<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 d78bc809..5bdcf1ba 100644
--- a/dashboard/src/lib/components/HeaderNav.svelte
+++ b/dashboard/src/lib/components/HeaderNav.svelte
@@ -6,10 +6,6 @@
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) {
@@ -39,15 +35,11 @@
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-light-gray'}"
+ : 'text-exo-medium-gray'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -88,9 +80,8 @@
</button>
<!-- Right: Home + Downloads -->
- <nav
+ <div
class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4"
- aria-label="Main navigation"
>
{#if showHome}
<button
@@ -119,56 +110,20 @@
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"
>
- {#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}
+ <svg
+ class="w-4 h-4"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <path d="M12 3v12" />
+ <path d="M7 12l5 5 5-5" />
+ <path d="M5 21h14" />
+ </svg>
Downloads
</a>
- </nav>
+ </div>
</header>
diff --git a/dashboard/src/lib/components/ModelCard.svelte b/dashboard/src/lib/components/ModelCard.svelte
index b432b7a8..561c325b 100644
--- a/dashboard/src/lib/components/ModelCard.svelte
+++ b/dashboard/src/lib/components/ModelCard.svelte
@@ -567,17 +567,11 @@
<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"
@@ -587,26 +581,6 @@
</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 cf21c727..84a93ee7 100644
--- a/dashboard/src/lib/components/ModelPickerModal.svelte
+++ b/dashboard/src/lib/components/ModelPickerModal.svelte
@@ -512,18 +512,6 @@
);
});
- // 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)) {
@@ -852,60 +840,7 @@
{/if}
</div>
{:else}
- <!-- 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}
+ {#each filteredGroups 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
deleted file mode 100644
index 6bcb77b8..00000000
--- a/dashboard/src/lib/components/ToastContainer.svelte
+++ /dev/null
@@ -1,117 +0,0 @@
-<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 5a5afc78..ebb2d0df 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -587,12 +587,6 @@ 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;
@@ -1296,19 +1290,7 @@ 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);
}
}
@@ -1835,7 +1817,7 @@ class AppStore {
assistantMessage.id,
(msg) => {
msg.content =
- "No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically.";
+ "Error: No model available. Please launch an instance first.";
},
);
this.syncActiveMessagesIfNeeded(targetConversationId);
@@ -2273,7 +2255,7 @@ class AppStore {
const modelToUse = this.getModelForRequest();
if (!modelToUse) {
throw new Error(
- "No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically.",
+ "No model selected and no running instances available. Please launch an instance first.",
);
}
@@ -3162,9 +3144,6 @@ 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
deleted file mode 100644
index e9e62019..00000000
--- a/dashboard/src/lib/stores/toast.svelte.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * 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 295c9dc0..d249e9cf 100644
--- a/dashboard/src/routes/+layout.svelte
+++ b/dashboard/src/routes/+layout.svelte
@@ -1,7 +1,5 @@
<script lang="ts">
import "../app.css";
- import ToastContainer from "$lib/components/ToastContainer.svelte";
- import ConnectionBanner from "$lib/components/ConnectionBanner.svelte";
let { children } = $props();
</script>
@@ -12,7 +10,5 @@
</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 20f196ee..5f7dcf04 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -36,7 +36,6 @@
createConversation,
setSelectedChatModel,
selectedChatModel,
- sendMessage,
debugMode,
toggleDebugMode,
topologyOnlyMode,
@@ -53,11 +52,9 @@
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, slide } from "svelte/transition";
- import { tweened } from "svelte/motion";
- import { cubicInOut, cubicOut } from "svelte/easing";
+ import { fade, fly } from "svelte/transition";
+ import { cubicInOut } from "svelte/easing";
import { onMount } from "svelte";
const chatStarted = $derived(hasStartedChat());
@@ -143,26 +140,6 @@
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;
@@ -253,171 +230,6 @@
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<{
@@ -562,9 +374,6 @@
// 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());
@@ -894,20 +703,6 @@
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() {
@@ -987,30 +782,7 @@
? Array.from(nodeFilter)
: undefined;
- 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", {
+ const response = await fetch("/meta_instance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1024,13 +796,8 @@
if (!response.ok) {
const errorText = await response.text();
- console.error("Failed to launch instance:", errorText);
- addToast({
- type: "error",
- message: `Failed to launch model: ${errorText}`,
- });
+ console.error("Failed to create meta instance:", 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);
@@ -1052,11 +819,7 @@
setTimeout(scrollToBottom, 1000);
}
} catch (error) {
- console.error("Error launching instance:", error);
- addToast({
- type: "error",
- message: "Failed to launch model. Check console for details.",
- });
+ console.error("Error creating meta instance:", error);
} finally {
launchingModelId = null;
}
@@ -1665,7 +1428,6 @@
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
@@ -2654,741 +2416,190 @@
class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden"
>
<!-- Scanline overlay -->
- {#if !showOnboarding}
+ <div
+ class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"
+ ></div>
+
+ <!-- Shooting Stars Background - one every ~15s -->
+ <div class="shooting-stars">
<div
- class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"
+ class="shooting-star"
+ style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"
></div>
-
- <!-- Shooting Stars Background - one every ~15s -->
- <div class="shooting-stars">
- <div
- class="shooting-star"
- style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"
- ></div>
- <div
- class="shooting-star"
- style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"
- ></div>
- <div
- class="shooting-star"
- style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"
- ></div>
- </div>
+ <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}
- {#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="text-center max-w-lg px-8"
- in:fade={{ duration: 400 }}
- out:fade={{ duration: 200 }}
- >
- <div class="mb-8">
- <div
- class="text-5xl font-mono font-bold text-exo-yellow tracking-wider mb-4"
- >
- exo
- </div>
- <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={() => (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-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 === 2}
- <!-- Step 2: Add more devices, run bigger models -->
+ <!-- Main Content -->
+ <main class="flex-1 flex overflow-hidden relative">
+ <!-- Left: Conversation History Sidebar (hidden in topology-only mode or when toggled off) -->
+ {#if !topologyOnlyEnabled && sidebarVisible}
+ <div class="w-80 flex-shrink-0 border-r border-exo-yellow/10">
+ <ChatSidebar class="h-full" />
+ </div>
+ {/if}
+
+ {#if topologyOnlyEnabled}
+ <!-- TOPOLOGY ONLY MODE: Full-screen topology -->
+ <div
+ class="flex-1 flex flex-col min-h-0 min-w-0 p-4"
+ in:fade={{ duration: 300 }}
+ >
<div
- class="flex flex-col items-center w-full max-w-2xl px-8"
- in:fade={{ duration: 400 }}
- out:fade={{ duration: 200 }}
+ class="flex-1 relative bg-exo-dark-gray/40 rounded-lg overflow-hidden"
>
- <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>
-
- <!-- 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%"
- >
- <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"
- 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;"
- >
- 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;"
- >
- 36 GB
- </text>
- </g>
- {/if}
-
- <!-- 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;"
- >
- 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}
-
- <!-- 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 }}
- >
- 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 }}
- />
- <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}
-
- <!-- 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}
-
- <!-- 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}
-
- <!-- 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}
+ <TopologyGraph
+ class="w-full h-full"
+ highlightedNodes={highlightedNodes()}
+ filteredNodes={nodeFilter}
+ onNodeClick={togglePreviewNodeFilter}
+ />
- <!-- "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>
+ {@render clusterWarnings()}
- <!-- 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 }}
+ <!-- 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"
>
- Continue
<svg
- class="w-4 h-4"
+ class="w-5 h-5 text-blue-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
- stroke-width="2.5"
+ stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
- d="M13 7l5 5m0 0l-5 5m5-5H6"
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
- </button>
+ <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>
{/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>
+
+ <!-- Exit topology-only mode button -->
<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"
+ 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"
>
- Continue
<svg
- class="w-4 h-4"
+ class="w-5 h-5 text-exo-yellow"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
- stroke-width="2.5"
+ stroke-width="2"
>
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M13 7l5 5m0 0l-5 5m5-5H6"
- />
+ <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>
- {: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>
+ </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}
+ />
- {#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}
+ {@render clusterWarnings()}
- {#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'}"
+ <!-- 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"
>
- <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-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 text-gray-400"
+ class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -3397,255 +2608,55 @@
<path
stroke-linecap="round"
stroke-linejoin="round"
- d="M9 5l7 7-7 7"
+ d="M6 18L18 6M6 6l12 12"
/>
</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>
+ </button>
+ </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"
- >
+ <!-- Tooltip on hover -->
<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
+ 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>
- <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>
+ {/if}
- <!-- 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}
+ <!-- Node Filter Indicator (top-right corner) -->
+ {#if isFilterActive()}
<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"
+ 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-5 h-5 text-blue-400 flex-shrink-0"
+ class="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -3654,307 +2665,905 @@
<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"
+ d="M6 18L18 6M6 6l12 12"
/>
</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>
+ </button>
{/if}
+ </div>
- <!-- 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>
+ <!-- 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>
- {: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}
+ <!-- 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="absolute inset-0 flex items-center justify-center bg-exo-dark-gray/80"
- in:fade={{ duration: 200 }}
- out:fade={{ duration: 300 }}
+ 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"
>
- <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}
+ Instances
+ </h3>
<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()}
+ class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
+ ></div>
+ </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
+ 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="flex items-center gap-2 px-3 py-2 rounded border border-blue-400/50 bg-blue-400/10 backdrop-blur-sm"
+ 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 === " ") {
+ if (
+ instanceModelId &&
+ instanceModelId !== "Unknown" &&
+ instanceModelId !== "Unknown Model"
+ ) {
+ setSelectedChatModel(instanceModelId);
+ }
+ }
+ }}
>
- <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>
+ <!-- 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>
- <!-- 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"
- >
- <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"
+ <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"
>
- <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
+ <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"
>
- </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"
+ 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>
+ {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
+ <a
+ class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
+ href={`https://huggingface.co/${instanceModelId}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ aria-label="View model on Hugging Face"
+ >
+ <span>Hugging Face</span>
+ <svg
+ class="w-3.5 h-3.5"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <path d="M14 3h7v7" />
+ <path d="M10 14l11-11" />
+ <path
+ d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
+ />
+ </svg>
+ </a>
+ {/if}
+ {#if instanceInfo.nodeNames.length > 0}
+ <div class="text-white/60 text-xs font-mono">
+ {instanceInfo.nodeNames.join(", ")}
+ </div>
+ {/if}
+ {#if debugEnabled && instanceConnections.length > 0}
+ <div class="mt-2 space-y-1">
+ {#each instanceConnections as conn}
+ <div
+ class="text-[11px] leading-snug font-mono text-white/70"
+ >
+ <span>{conn.from} -> {conn.to}: {conn.ip}</span>
+ <span
+ class={conn.missingIface
+ ? "text-red-400"
+ : "text-white/60"}
+ >
+ ({conn.ifaceLabel})</span
+ >
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Download Progress -->
+ {#if downloadInfo.isDownloading && downloadInfo.progress}
+ <div class="mt-2 space-y-1">
+ <div class="flex justify-between text-xs font-mono">
+ <span class="text-blue-400"
+ >{downloadInfo.progress.percentage.toFixed(
+ 1,
+ )}%</span
+ >
+ <span class="text-exo-light-gray"
+ >{formatBytes(
+ downloadInfo.progress.downloadedBytes,
+ )}/{formatBytes(
+ downloadInfo.progress.totalBytes,
+ )}</span
+ >
+ </div>
+ <div
+ class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+ >
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+ style="width: {downloadInfo.progress
+ .percentage}%"
+ ></div>
+ </div>
+ <div
+ class="flex justify-between text-xs font-mono text-exo-light-gray"
+ >
+ <span
+ >{formatSpeed(
+ downloadInfo.progress.speed,
+ )}</span
+ >
+ <span
+ >ETA: {formatEta(
+ downloadInfo.progress.etaMs,
+ )}</span
+ >
+ <span
+ >{downloadInfo.progress
+ .completedFiles}/{downloadInfo.progress
+ .totalFiles} files</span
+ >
+ </div>
+ </div>
+ {#if downloadInfo.perNode.length > 0}
+ <div
+ class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
+ >
+ {#each downloadInfo.perNode as nodeProg}
+ {@const nodePercent = Math.min(
+ 100,
+ Math.max(0, nodeProg.progress.percentage),
+ )}
+ {@const isExpanded =
+ instanceDownloadExpandedNodes.has(
+ nodeProg.nodeId,
+ )}
+ <div
+ class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
+ >
+ <button
+ type="button"
+ class="w-full text-left space-y-1.5"
+ onclick={() =>
+ toggleInstanceDownloadDetails(
+ nodeProg.nodeId,
+ )}
+ >
+ <div
+ class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+ >
+ <span class="text-white/80 truncate pr-2"
+ >{nodeProg.nodeName}</span
+ >
+ <span
+ class="flex items-center gap-1 text-blue-300"
+ >
+ {nodePercent.toFixed(1)}%
+ <svg
+ class="w-3 h-3 text-exo-light-gray"
+ viewBox="0 0 20 20"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ d="M6 8l4 4 4-4"
+ class={isExpanded
+ ? "transform rotate-180 origin-center transition-transform duration-150"
+ : "transition-transform duration-150"}
+ ></path>
+ </svg>
+ </span>
+ </div>
+ <div
+ class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+ >
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+ style="width: {nodePercent.toFixed(1)}%"
+ ></div>
+ </div>
+ <div
+ class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+ >
+ <span
+ >{formatBytes(
+ nodeProg.progress.downloadedBytes,
+ )} / {formatBytes(
+ nodeProg.progress.totalBytes,
+ )}</span
+ >
+ <span
+ >{formatSpeed(nodeProg.progress.speed)} •
+ ETA {formatEta(
+ nodeProg.progress.etaMs,
+ )}</span
+ >
+ </div>
+ </button>
+
+ {#if isExpanded}
+ <div class="mt-2 space-y-1.5">
+ {#if nodeProg.progress.files.length === 0}
+ <div
+ class="text-[11px] font-mono text-exo-light-gray/70"
+ >
+ No file details reported.
+ </div>
+ {:else}
+ {#each nodeProg.progress.files as f}
+ {@const filePercent = Math.min(
+ 100,
+ Math.max(0, f.percentage ?? 0),
+ )}
+ {@const isFileComplete =
+ filePercent >= 100}
+ <div
+ class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
+ >
+ <div
+ class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
+ >
+ <span class="truncate pr-2"
+ >{f.name}</span
+ >
+ <span
+ class={isFileComplete
+ ? "text-green-400"
+ : "text-white/80"}
+ >{filePercent.toFixed(1)}%</span
+ >
+ </div>
+ <div
+ class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
+ >
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
+ ? 'from-green-500 to-green-400'
+ : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
+ style="width: {filePercent.toFixed(
+ 1,
+ )}%"
+ ></div>
+ </div>
+ <div
+ class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
+ >
+ <span
+ >{formatBytes(
+ f.downloadedBytes,
+ )} / {formatBytes(
+ f.totalBytes,
+ )}</span
+ >
+ <span
+ >{formatSpeed(f.speed)} • ETA {formatEta(
+ f.etaMs,
+ )}</span
+ >
+ </div>
+ </div>
+ {/each}
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+ <div
+ class="text-xs text-blue-400 font-mono tracking-wider mt-1"
+ >
+ DOWNLOADING
+ </div>
+ {:else}
+ <div
+ class="text-xs {getStatusColor(
+ downloadInfo.statusText,
+ )} font-mono tracking-wider mt-1"
+ >
+ {downloadInfo.statusText}
+ </div>
+ {#if 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}
+ </div>
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Models Panel - Scrollable -->
+ <div class="p-4 flex-1 overflow-y-auto">
+ <!-- Panel Header -->
+ <div class="flex items-center gap-2 mb-3 flex-shrink-0">
+ <div class="w-2 h-2 border border-exo-yellow/60 rotate-45"></div>
+ <h3
+ class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
+ >
+ Launch Instance
+ </h3>
+ <div
+ class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
+ ></div>
+ <span class="text-sm text-white/70 font-mono"
+ >{models.length} models</span
+ >
+ </div>
+
+ <!-- Model 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"
+ >
+ <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>
+ {:else}
+ <span class="text-exo-light-gray">{selectedModelId}</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"
+ >
+ <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>
- </button>
- {/if}
+ </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>
+
+ <!-- Minimum Nodes (discrete slider with drag support) -->
+ <div>
+ <div class="text-xs text-white/70 font-mono mb-2">
+ Minimum Nodes:
+ </div>
+ <!-- Discrete slider track with drag support -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ bind:this={sliderTrackElement}
+ class="relative h-16 cursor-pointer select-none px-2 pr-6"
+ onmousedown={handleSliderMouseDown}
+ ontouchstart={handleSliderTouchStart}
+ >
+ <!-- Track background - extends full width to align with edge dots -->
+ <div
+ class="absolute top-6 left-0 right-0 h-2 bg-exo-medium-gray/50 rounded-full"
+ ></div>
+ <!-- Active track (fills up to selected) -->
+ {#if availableMinNodes > 1}
+ <div
+ class="absolute top-6 left-0 h-2 bg-white/30 rounded-full transition-all pointer-events-none"
+ style="width: {((selectedMinNodes - 1) /
+ (availableMinNodes - 1)) *
+ 100}%"
+ ></div>
+ {/if}
+ <!-- Dots and labels for each node count -->
+ {#each Array.from({ length: availableMinNodes }, (_, i) => i + 1) as n}
+ {@const isValid = validMinNodeCounts().has(n)}
+ {@const isSelected = selectedMinNodes === n}
+ {@const position =
+ availableMinNodes > 1
+ ? ((n - 1) / (availableMinNodes - 1)) * 100
+ : 50}
+ <div
+ class="absolute flex flex-col items-center pointer-events-none"
+ style="left: {position}%; top: 0; transform: translateX(-50%);"
+ >
+ <!-- Dot -->
+ <span
+ class="rounded-full transition-all {isSelected
+ ? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]'
+ : isValid
+ ? 'w-4 h-4 bg-exo-light-gray/70 mt-1'
+ : 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
+ ></span>
+ <!-- Number label below dot -->
+ <span
+ class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected
+ ? 'text-exo-yellow font-bold'
+ : isValid
+ ? 'text-white/70'
+ : 'text-white/30'}">{n}</span
+ >
+ </div>
+ {/each}
+ </div>
+ </div>
</div>
- <!-- 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>
+ <!-- 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}
- <ChatForm
- placeholder={instanceCount === 0
- ? "Choose a model above to start chatting"
- : "Ask anything"}
- showHelperText={false}
- showModelSelector={true}
- modelTasks={modelTasks()}
- modelCapabilities={modelCapabilities()}
- />
- </div>
+ {/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 Sidebar: Instance Controls (wider on welcome page for better visibility) -->
+ <!-- Right: Mini-Map Sidebar -->
+ {#if minimized}
<aside
- class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0"
- aria-label="Instance controls"
+ 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 }}
>
- <!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
+ <!-- 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-shrink-0">
+ <div class="p-4 flex-1">
<!-- Panel Header -->
<div class="flex items-center gap-2 mb-4">
<div
@@ -3969,10 +3578,8 @@
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"
+ 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}
@@ -4029,8 +3636,8 @@
class="relative group cursor-pointer"
role="button"
tabindex="0"
- transition:slide={{ duration: 250, easing: cubicOut }}
- onmouseenter={() => (hoveredInstanceId = id)}
+ onmouseenter={() =>
+ (hoveredInstanceId = item.instanceId ?? id)}
onmouseleave={() => (hoveredInstanceId = null)}
onclick={() => {
if (
@@ -4100,15 +3707,15 @@
></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'
+ 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 group-hover:border-red-500/50'
+ ? 'border-red-500/30 border-l-red-400'
: isLoading
- ? 'border-exo-yellow/30 border-l-yellow-400 group-hover:border-exo-yellow/50'
+ ? 'border-exo-yellow/30 border-l-yellow-400'
: 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"
+ ? 'border-green-500/30 border-l-green-400'
+ : 'border-teal-500/30 border-l-teal-400'} p-3"
>
<div class="flex justify-between items-start mb-2 pl-2">
<div class="flex items-center gap-2">
@@ -4144,25 +3751,10 @@
>
{instanceModelId}
</div>
- <div
- class="flex items-center gap-2 text-white/60 text-xs font-mono"
- >
- <span
- >{instanceInfo.sharding} · {instanceInfo.instanceType}</span
+ <div class="text-white/60 text-xs font-mono">
+ Strategy: <span class="text-white/80"
+ >{instanceInfo.sharding} ({instanceInfo.instanceType})</span
>
- <span
- class="px-1.5 py-0.5 text-[10px] tracking-wider uppercase rounded transition-all duration-300 {isDownloading
- ? 'bg-blue-500/15 text-blue-400'
- : isFailed
- ? 'bg-red-500/15 text-red-400'
- : isLoading
- ? 'bg-yellow-500/15 text-yellow-400'
- : isReady
- ? 'bg-green-500/15 text-green-400'
- : 'bg-teal-500/15 text-teal-400'}"
- >
- {statusText}
- </span>
</div>
{#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
<a
@@ -4416,43 +4008,18 @@
{/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
+ class="text-xs text-blue-400 font-mono tracking-wider mt-1"
+ >
+ DOWNLOADING
</div>
{: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"
- >
- 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
+ class="text-xs {getStatusColor(
+ downloadInfo.statusText,
+ )} font-mono tracking-wider mt-1"
+ >
+ {downloadInfo.statusText}
</div>
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
<div
@@ -4493,943 +4060,36 @@
</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>
-
- <!-- 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"
- >
- <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>
- {:else}
- <span class="text-exo-light-gray">{selectedModelId}</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"
- >
- <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-3 h-3 transition-transform duration-200 {showAdvancedOptions
- ? 'rotate-90'
- : ''}"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M9 5l7 7-7 7"
- />
- </svg>
- Advanced Options
- </button>
-
- {#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>
-
- <!-- 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>
-
- <!-- 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
- bind:this={sliderTrackElement}
- class="relative h-16 cursor-pointer select-none px-2 pr-6"
- onmousedown={handleSliderMouseDown}
- ontouchstart={handleSliderTouchStart}
- >
- <!-- 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>
- </div>
- </div>
- {/if}
- </div>
-
- <!-- Selected Model Preview -->
- <div class="space-y-3">
- {#if models.length === 0}
- <div class="text-center py-8">
- <div
- class="text-xs text-white/70 font-mono tracking-wider uppercase"
- >
- Loading models...
- </div>
- </div>
- {:else if loadingPreviews}
- <div class="text-center py-8">
- <div
- class="text-xs text-exo-yellow font-mono tracking-wider uppercase animate-pulse"
- >
- Loading preview...
- </div>
- </div>
- {:else}
- {@const selectedModel = models.find(
- (m) => m.id === selectedModelId,
- )}
- {@const allPreviews = filteredPreviews()}
- {#if selectedModel && allPreviews.length > 0}
- {@const downloadStatus = getModelDownloadStatus(
- selectedModel.id,
- )}
- {@const tags = modelTags()[selectedModel.id] || []}
- <div class="space-y-3">
- {#each allPreviews as apiPreview, i}
- <div
- role="group"
- onmouseenter={() => {
- if (apiPreview.memory_delta_by_node) {
- hoveredPreviewNodes = new Set(
- Object.entries(apiPreview.memory_delta_by_node)
- .filter(([, delta]) => (delta ?? 0) > 0)
- .map(([nodeId]) => nodeId),
- );
- }
- }}
- onmouseleave={() => (hoveredPreviewNodes = new Set())}
- >
- <ModelCard
- model={selectedModel}
- isLaunching={launchingModelId === selectedModel.id}
- {downloadStatus}
- nodes={data?.nodes ?? {}}
- sharding={apiPreview.sharding}
- runtime={apiPreview.instance_meta}
- onLaunch={() =>
- launchInstance(selectedModel.id, apiPreview)}
- {tags}
- {apiPreview}
- modelIdOverride={apiPreview.model_id}
- />
- </div>
- {/each}
- </div>
- {:else if selectedModel}
- <div class="text-center py-4">
- <div class="text-xs text-white/50 font-mono">
- No valid configurations for current settings
- </div>
- </div>
- {/if}
- {/if}
- </div>
- </div>
</aside>
- </div>
- {:else}
- <!-- CHAT STATE: Chat + Mini-Map -->
- <div class="flex-1 flex overflow-hidden">
- <!-- Chat Area -->
- <div
- class="flex-1 flex flex-col min-w-0 overflow-hidden"
- in:fade={{ duration: 300, delay: 100 }}
- >
- <div
- class="flex-1 overflow-y-auto px-8 py-6"
- bind:this={chatScrollRef}
- 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="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
- ></div>
- <h3
- class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
- >
- Instances
- </h3>
- <div
- class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
- ></div>
- </div>
- <div
- class="space-y-3 max-h-72 xl:max-h-96 overflow-y-auto overflow-x-hidden py-px pr-1"
- >
- {#each Object.entries(instanceData) as [id, instance]}
- {@const downloadInfo = getInstanceDownloadStatus(
- id,
- instance,
- )}
- {@const statusText = downloadInfo.statusText}
- {@const isDownloading = downloadInfo.isDownloading}
- {@const isFailed = statusText === "FAILED"}
- {@const isLoading =
- statusText === "LOADING" ||
- statusText === "WARMING UP" ||
- statusText === "WAITING"}
- {@const isReady =
- statusText === "READY" || statusText === "LOADED"}
- {@const isRunning = statusText === "RUNNING"}
- <!-- Instance Card -->
- {@const instanceModelId = getInstanceModelId(instance)}
- {@const instanceInfo = getInstanceInfo(instance)}
- {@const instanceConnections =
- getInstanceConnections(instance)}
- <div
- class="relative group cursor-pointer"
- role="button"
- tabindex="0"
- onmouseenter={() => (hoveredInstanceId = id)}
- onmouseleave={() => (hoveredInstanceId = null)}
- onclick={() => {
- if (
- instanceModelId &&
- instanceModelId !== "Unknown" &&
- instanceModelId !== "Unknown Model"
- ) {
- setSelectedChatModel(instanceModelId);
- }
- }}
- onkeydown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- if (
- instanceModelId &&
- instanceModelId !== "Unknown" &&
- instanceModelId !== "Unknown Model"
- ) {
- setSelectedChatModel(instanceModelId);
- }
- }
- }}
- >
- <!-- Corner accents -->
- <div
- class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
- <div
- class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
- <div
- class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
- <div
- class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading
- ? 'border-blue-500/50'
- : isFailed
- ? 'border-red-500/50'
- : isLoading
- ? 'border-yellow-500/50'
- : isReady
- ? 'border-green-500/50'
- : 'border-teal-500/50'}"
- ></div>
-
- <div
- class="bg-exo-dark-gray/60 border border-l-2 {isDownloading
- ? 'border-blue-500/30 border-l-blue-400'
- : isFailed
- ? 'border-red-500/30 border-l-red-400'
- : isLoading
- ? 'border-exo-yellow/30 border-l-yellow-400'
- : isReady
- ? 'border-green-500/30 border-l-green-400'
- : 'border-teal-500/30 border-l-teal-400'} p-3"
- >
- <div
- class="flex justify-between items-start mb-2 pl-2"
- >
- <div class="flex items-center gap-2">
- <div
- class="w-1.5 h-1.5 {isDownloading
- ? 'bg-blue-400 animate-pulse'
- : isFailed
- ? 'bg-red-400'
- : isLoading
- ? 'bg-yellow-400 animate-pulse'
- : isReady
- ? 'bg-green-400'
- : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"
- ></div>
- <span
- class="text-exo-light-gray font-mono text-sm tracking-wider"
- >{id.slice(0, 8).toUpperCase()}</span
- >
- </div>
- <button
- onclick={() => deleteInstance(id)}
- class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
- >
- DELETE
- </button>
- </div>
- <div class="pl-2">
- <div
- class="text-exo-yellow text-xs font-mono tracking-wide truncate"
- >
- {getInstanceModelId(instance)}
- </div>
- <div
- class="flex items-center gap-2 text-white/60 text-xs font-mono"
- >
- <span
- >{instanceInfo.sharding} · {instanceInfo.instanceType}</span
- >
- <span
- class="px-1.5 py-0.5 text-[10px] tracking-wider uppercase rounded transition-all duration-300 {isDownloading
- ? 'bg-blue-500/15 text-blue-400'
- : isFailed
- ? 'bg-red-500/15 text-red-400'
- : isLoading
- ? 'bg-yellow-500/15 text-yellow-400'
- : isReady
- ? 'bg-green-500/15 text-green-400'
- : 'bg-teal-500/15 text-teal-400'}"
- >
- {statusText}
- </span>
- </div>
- {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
- <a
- class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
- href={`https://huggingface.co/${instanceModelId}`}
- target="_blank"
- rel="noreferrer noopener"
- aria-label="View model on Hugging Face"
- >
- <span>Hugging Face</span>
- <svg
- class="w-3.5 h-3.5"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- >
- <path d="M14 3h7v7" />
- <path d="M10 14l11-11" />
- <path
- d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
- />
- </svg>
- </a>
- {/if}
- {#if instanceInfo.nodeNames.length > 0}
- <div class="text-white/60 text-xs font-mono">
- {instanceInfo.nodeNames.join(", ")}
- </div>
- {/if}
- {#if debugEnabled && instanceConnections.length > 0}
- <div class="mt-2 space-y-1">
- {#each instanceConnections as conn}
- <div
- class="text-[11px] leading-snug font-mono text-white/70"
- >
- <span
- >{conn.from} -> {conn.to}: {conn.ip}</span
- >
- <span
- class={conn.missingIface
- ? "text-red-400"
- : "text-white/60"}
- >
- ({conn.ifaceLabel})</span
- >
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Download Progress -->
- {#if downloadInfo.isDownloading && downloadInfo.progress}
- <div class="mt-2 space-y-1">
- <div
- class="flex justify-between text-xs font-mono"
- >
- <span class="text-blue-400"
- >{downloadInfo.progress.percentage.toFixed(
- 1,
- )}%</span
- >
- <span class="text-exo-light-gray"
- >{formatBytes(
- downloadInfo.progress.downloadedBytes,
- )}/{formatBytes(
- downloadInfo.progress.totalBytes,
- )}</span
- >
- </div>
- <div
- class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
- >
- <div
- class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
- style="width: {downloadInfo.progress
- .percentage}%"
- ></div>
- </div>
- <div
- class="flex justify-between text-xs font-mono text-exo-light-gray"
- >
- <span
- >{formatSpeed(
- downloadInfo.progress.speed,
- )}</span
- >
- <span
- >ETA: {formatEta(
- downloadInfo.progress.etaMs,
- )}</span
- >
- <span
- >{downloadInfo.progress
- .completedFiles}/{downloadInfo.progress
- .totalFiles} files</span
- >
- </div>
- </div>
- {#if downloadInfo.perNode.length > 0}
- <div
- class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
- >
- {#each downloadInfo.perNode as nodeProg}
- {@const nodePercent = Math.min(
- 100,
- Math.max(0, nodeProg.progress.percentage),
- )}
- {@const isExpanded =
- instanceDownloadExpandedNodes.has(
- nodeProg.nodeId,
- )}
- <div
- class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
- >
- <button
- type="button"
- class="w-full text-left space-y-1.5"
- onclick={() =>
- toggleInstanceDownloadDetails(
- nodeProg.nodeId,
- )}
- >
- <div
- class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
- >
- <span
- class="text-white/80 truncate pr-2"
- >{nodeProg.nodeName}</span
- >
- <span
- class="flex items-center gap-1 text-blue-300"
- >
- {nodePercent.toFixed(1)}%
- <svg
- class="w-3 h-3 text-exo-light-gray"
- viewBox="0 0 20 20"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- >
- <path
- d="M6 8l4 4 4-4"
- class={isExpanded
- ? "transform rotate-180 origin-center transition-transform duration-150"
- : "transition-transform duration-150"}
- ></path>
- </svg>
- </span>
- </div>
- <div
- class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
- >
- <div
- class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
- style="width: {nodePercent.toFixed(
- 1,
- )}%"
- ></div>
- </div>
- <div
- class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
- >
- <span
- >{formatBytes(
- nodeProg.progress.downloadedBytes,
- )} / {formatBytes(
- nodeProg.progress.totalBytes,
- )}</span
- >
- <span
- >{formatSpeed(
- nodeProg.progress.speed,
- )} • ETA {formatEta(
- nodeProg.progress.etaMs,
- )}</span
- >
- </div>
- </button>
-
- {#if isExpanded}
- <div class="mt-2 space-y-1.5">
- {#if nodeProg.progress.files.length === 0}
- <div
- class="text-[11px] font-mono text-exo-light-gray/70"
- >
- No file details reported.
- </div>
- {:else}
- {#each nodeProg.progress.files as f}
- {@const filePercent = Math.min(
- 100,
- Math.max(0, f.percentage ?? 0),
- )}
- {@const isFileComplete =
- filePercent >= 100}
- <div
- class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
- >
- <div
- class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
- >
- <span class="truncate pr-2"
- >{f.name}</span
- >
- <span
- class={isFileComplete
- ? "text-green-400"
- : "text-white/80"}
- >{filePercent.toFixed(
- 1,
- )}%</span
- >
- </div>
- <div
- class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
- >
- <div
- class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
- ? 'from-green-500 to-green-400'
- : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
- style="width: {filePercent.toFixed(
- 1,
- )}%"
- ></div>
- </div>
- <div
- class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
- >
- <span
- >{formatBytes(
- f.downloadedBytes,
- )} / {formatBytes(
- f.totalBytes,
- )}</span
- >
- <span
- >{formatSpeed(f.speed)} • ETA
- {formatEta(f.etaMs)}</span
- >
- </div>
- </div>
- {/each}
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
- <div class="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>
- {: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"
- >
- 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}
- </div>
- </div>
- </div>
- {/each}
- </div>
- </div>
- {/if}
- </aside>
- {/if}
- </div>
- {/if}
- </main>
- {/if}
+ {/if}
+ </div>
+ {/if}
+ </main>
</div>
-{#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}
+<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}
+/>
diff --git a/packaging/dmg/background.png b/packaging/dmg/background.png
deleted file mode 100644
index 5e56d8ab..00000000
Binary files a/packaging/dmg/background.png and /dev/null differ
diff --git a/packaging/dmg/create-dmg.sh b/packaging/dmg/create-dmg.sh
deleted file mode 100755
index cd68b2a7..00000000
--- a/packaging/dmg/create-dmg.sh
+++ /dev/null
@@ -1,112 +0,0 @@
-#!/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
deleted file mode 100644
index ac3dc649..00000000
--- a/packaging/dmg/generate-background.py
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/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 2742832e..ffdb5458 100644
--- a/src/exo/utils/banner.py
+++ b/src/exo/utils/banner.py
@@ -1,27 +1,8 @@
-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"""
╔═══════════════════════════════════════════════════════════════════════╗
║ ║
@@ -49,14 +30,3 @@ 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()
← 490d2e46 feat: better onboarding UX for new users (#1479)
·
back to Exo
·
Revert "Add MetaInstance declarative layer (#1447)" eccc6298 →