← back to Exo
feat: add user context prompt and GitHub issue option to macOS bug report (#1544)
7312c535b4938fe61bd1913b2ac232b3eae54834 · 2026-02-22 07:37:43 -0800 · Alex Cheema
## Summary
- When clicking "Send Bug Report" in the macOS app, users are now
prompted with "What's the issue? (optional)" before the diagnostic
upload begins
- The user's description is included in the uploaded report JSON
(`user_description` field)
- After successful upload, a "Create GitHub Issue" button opens the
browser to `github.com/exo-explore/exo/issues/new` pre-filled with the
user's description, macOS version, and EXO version
## Changes
- **`ContentView.swift`**: Replaced simple button with multi-phase
inline UI (idle → prompting → sending → success/failure). Added
`openGitHubIssue()` helper using `URLComponents` for pre-filled GitHub
issue URLs.
- **`BugReportService.swift`**: Added `userDescription` parameter to
`sendReport()` and `makeReportJson()`, included in the JSON payload when
non-empty.
- Removed the previous `BugReportModal.svelte` approach (the feature
belongs in the macOS app, not the dashboard).
## Test plan
- [ ] Build the Xcode project and run the macOS app
- [ ] Click "Send Bug Report" → verify text prompt appears
- [ ] Type a description, click Send → verify upload succeeds and
"Create GitHub Issue" button appears
- [ ] Click "Create GitHub Issue" → verify browser opens with pre-filled
template
- [ ] Test Cancel returns to idle, test empty description still works
- [ ] Test failure case (e.g. with exo stopped) shows error and dismiss
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Files touched
M app/EXO/EXO/ContentView.swiftM app/EXO/EXO/Services/BugReportService.swift
Diff
commit 7312c535b4938fe61bd1913b2ac232b3eae54834
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date: Sun Feb 22 07:37:43 2026 -0800
feat: add user context prompt and GitHub issue option to macOS bug report (#1544)
## Summary
- When clicking "Send Bug Report" in the macOS app, users are now
prompted with "What's the issue? (optional)" before the diagnostic
upload begins
- The user's description is included in the uploaded report JSON
(`user_description` field)
- After successful upload, a "Create GitHub Issue" button opens the
browser to `github.com/exo-explore/exo/issues/new` pre-filled with the
user's description, macOS version, and EXO version
## Changes
- **`ContentView.swift`**: Replaced simple button with multi-phase
inline UI (idle → prompting → sending → success/failure). Added
`openGitHubIssue()` helper using `URLComponents` for pre-filled GitHub
issue URLs.
- **`BugReportService.swift`**: Added `userDescription` parameter to
`sendReport()` and `makeReportJson()`, included in the JSON payload when
non-empty.
- Removed the previous `BugReportModal.svelte` approach (the feature
belongs in the macOS app, not the dashboard).
## Test plan
- [ ] Build the Xcode project and run the macOS app
- [ ] Click "Send Bug Report" → verify text prompt appears
- [ ] Type a description, click Send → verify upload succeeds and
"Create GitHub Issue" button appears
- [ ] Click "Create GitHub Issue" → verify browser opens with pre-filled
template
- [ ] Test Cancel returns to idle, test empty description still works
- [ ] Test failure case (e.g. with exo stopped) shows error and dismiss
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
---
app/EXO/EXO/ContentView.swift | 185 +++++++++++++++++++++++-----
app/EXO/EXO/Services/BugReportService.swift | 12 +-
2 files changed, 164 insertions(+), 33 deletions(-)
diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift
index 688fba5b..4bd620ea 100644
--- a/app/EXO/EXO/ContentView.swift
+++ b/app/EXO/EXO/ContentView.swift
@@ -21,8 +21,15 @@ struct ContentView: View {
@State private var showAllInstances = false
@State private var showAdvanced = false
@State private var showDebugInfo = false
- @State private var bugReportInFlight = false
- @State private var bugReportMessage: String?
+ private enum BugReportPhase: Equatable {
+ case idle
+ case prompting
+ case sending(String)
+ case success(String)
+ case failure(String)
+ }
+ @State private var bugReportPhase: BugReportPhase = .idle
+ @State private var bugReportUserDescription: String = ""
@State private var uninstallInProgress = false
@State private var pendingNamespace: String = ""
@State private var pendingHFToken: String = ""
@@ -611,39 +618,115 @@ struct ContentView: View {
}
private var sendBugReportButton: some View {
- VStack(alignment: .leading, spacing: 4) {
- Button {
- Task {
- await sendBugReport()
+ VStack(alignment: .leading, spacing: 6) {
+ switch bugReportPhase {
+ case .idle:
+ Button {
+ bugReportPhase = .prompting
+ bugReportUserDescription = ""
+ } label: {
+ HStack {
+ 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))
+ )
}
- } label: {
- HStack {
- if bugReportInFlight {
- ProgressView()
- .scaleEffect(0.6)
+ .buttonStyle(.plain)
+
+ case .prompting:
+ VStack(alignment: .leading, spacing: 6) {
+ Text("What's the issue? (optional)")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ TextEditor(text: $bugReportUserDescription)
+ .font(.caption2)
+ .frame(height: 60)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color.secondary.opacity(0.3), lineWidth: 1)
+ )
+ HStack(spacing: 8) {
+ Button("Send") {
+ Task {
+ await sendBugReport()
+ }
+ }
+ .font(.caption2)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ Button("Cancel") {
+ bugReportPhase = .idle
+ }
+ .font(.caption2)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
}
- Text("Send Bug Report")
- .font(.caption)
- .fontWeight(.semibold)
- Spacer()
}
- .padding(.vertical, 6)
- .padding(.horizontal, 8)
+ .padding(8)
.background(
RoundedRectangle(cornerRadius: 6)
- .fill(Color.accentColor.opacity(0.12))
+ .fill(Color.accentColor.opacity(0.06))
)
- }
- .buttonStyle(.plain)
- .disabled(bugReportInFlight)
- if let message = bugReportMessage {
- Text(message)
+ case .sending(let message):
+ HStack(spacing: 6) {
+ ProgressView()
+ .scaleEffect(0.6)
+ Text(message)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+
+ case .success(let message):
+ VStack(alignment: .leading, spacing: 6) {
+ Text(message)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Button {
+ openGitHubIssue()
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: "arrow.up.right.square")
+ .imageScale(.small)
+ Text("Create GitHub Issue")
+ .font(.caption2)
+ }
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ Button("Done") {
+ bugReportPhase = .idle
+ bugReportUserDescription = ""
+ }
.font(.caption2)
+ .buttonStyle(.plain)
.foregroundColor(.secondary)
- .fixedSize(horizontal: false, vertical: true)
+ }
+
+ case .failure(let message):
+ VStack(alignment: .leading, spacing: 4) {
+ Text(message)
+ .font(.caption2)
+ .foregroundColor(.red)
+ .fixedSize(horizontal: false, vertical: true)
+ Button("Dismiss") {
+ bugReportPhase = .idle
+ }
+ .font(.caption2)
+ .buttonStyle(.plain)
+ .foregroundColor(.secondary)
+ }
}
}
+ .animation(.easeInOut(duration: 0.2), value: bugReportPhase)
}
private var processToggleBinding: Binding<Bool> {
@@ -687,16 +770,58 @@ struct ContentView: View {
}
private func sendBugReport() async {
- bugReportInFlight = true
- bugReportMessage = "Collecting logs..."
+ bugReportPhase = .sending("Collecting logs...")
let service = BugReportService()
+ let description = bugReportUserDescription.trimmingCharacters(in: .whitespacesAndNewlines)
do {
- let outcome = try await service.sendReport(isManual: true)
- bugReportMessage = outcome.message
+ let outcome = try await service.sendReport(
+ isManual: true,
+ userDescription: description.isEmpty ? nil : description
+ )
+ if outcome.success {
+ bugReportPhase = .success(outcome.message)
+ } else {
+ bugReportPhase = .failure(outcome.message)
+ }
} catch {
- bugReportMessage = error.localizedDescription
+ bugReportPhase = .failure(error.localizedDescription)
+ }
+ }
+
+ private func openGitHubIssue() {
+ let description = bugReportUserDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ var bodyParts: [String] = []
+ bodyParts.append("## Describe the bug")
+ bodyParts.append("")
+ if !description.isEmpty {
+ bodyParts.append(description)
+ } else {
+ bodyParts.append("A clear and concise description of what the bug is.")
+ }
+ bodyParts.append("")
+ bodyParts.append("## Environment")
+ bodyParts.append("")
+ bodyParts.append("- macOS Version: \(ProcessInfo.processInfo.operatingSystemVersionString)")
+ bodyParts.append("- EXO Version: \(buildTag) (\(buildCommit))")
+ bodyParts.append("")
+ bodyParts.append("## Additional context")
+ bodyParts.append("")
+ bodyParts.append("A bug report with diagnostic logs was submitted via the app.")
+
+ let body = bodyParts.joined(separator: "\n")
+
+ var components = URLComponents(string: "https://github.com/exo-explore/exo/issues/new")!
+ components.queryItems = [
+ URLQueryItem(name: "template", value: "bug_report.md"),
+ URLQueryItem(name: "title", value: "[BUG] "),
+ URLQueryItem(name: "body", value: body),
+ URLQueryItem(name: "labels", value: "bug"),
+ ]
+
+ if let url = components.url {
+ NSWorkspace.shared.open(url)
}
- bugReportInFlight = false
}
private func showUninstallConfirmationAlert() {
diff --git a/app/EXO/EXO/Services/BugReportService.swift b/app/EXO/EXO/Services/BugReportService.swift
index 5c2cec95..14e5b885 100644
--- a/app/EXO/EXO/Services/BugReportService.swift
+++ b/app/EXO/EXO/Services/BugReportService.swift
@@ -38,7 +38,8 @@ struct BugReportService {
func sendReport(
baseURL: URL = URL(string: "http://127.0.0.1:52415")!,
now: Date = Date(),
- isManual: Bool = false
+ isManual: Bool = false,
+ userDescription: String? = nil
) async throws -> BugReportOutcome {
let timestamp = Self.runTimestampString(now)
let dayPrefix = Self.dayPrefixString(now)
@@ -60,7 +61,8 @@ struct BugReportService {
ifconfig: ifconfigText,
debugInfo: debugInfo,
isManual: isManual,
- clusterTbBridgeStatus: clusterTbBridgeStatus
+ clusterTbBridgeStatus: clusterTbBridgeStatus,
+ userDescription: userDescription
)
let eventLogFiles = readAllEventLogs()
@@ -306,7 +308,8 @@ struct BugReportService {
ifconfig: String,
debugInfo: DebugInfo,
isManual: Bool,
- clusterTbBridgeStatus: [[String: Any]]?
+ clusterTbBridgeStatus: [[String: Any]]?,
+ userDescription: String? = nil
) -> Data? {
let system = readSystemMetadata()
let exo = readExoMetadata()
@@ -323,6 +326,9 @@ struct BugReportService {
if let tbStatus = clusterTbBridgeStatus {
payload["cluster_thunderbolt_bridge"] = tbStatus
}
+ if let desc = userDescription, !desc.isEmpty {
+ payload["user_description"] = desc
+ }
return try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
}
← 18717023 chore: remove deprecated MlxIbv dashboard references (#1584)
·
back to Exo
·
fix: raise error when MlxJaccl requested without RDMA cycles a4c2aa2b →