Files
openagent/ios_dashboard/OpenAgentDashboard/ContentView.swift
Thomas Marchand a3d3437b1d OpenCode workspace host + MCP sync + iOS fixes (#27)
* Add multi-user auth and per-user control sessions

* Add mission store abstraction and auth UX polish

* Fix unused warnings in tooling

* Fix Bugbot review issues

- Prevent username enumeration by using generic error message
- Add pagination support to InMemoryMissionStore::list_missions
- Improve config error when JWT_SECRET missing but DASHBOARD_PASSWORD set

* Trim stored username in comparison for consistency

* Fix mission cleanup to also remove orphaned tree data

* Refactor Open Agent as OpenCode workspace host

* Remove chromiumoxide and pin @types/react

* Pin idna_adapter for MSRV compatibility

* Add host-mcp bin target

* Use isolated Playwright MCP sessions

* Allow Playwright MCP as root

* Fix iOS dashboard warnings

* Add autoFocus to username field in multi-user login mode

Mirrors the iOS implementation behavior where username field is focused
when multi-user auth mode is active.

* Fix Bugbot review issues

- Add conditional ellipsis for tool descriptions (only when > 32 chars)
- Add serde(default) to JWT usr field for backward compatibility

* Fix empty user ID fallback in multi-user auth

Add effective_user_id helper that falls back to username when id is empty,
preventing session sharing and token verification issues.

* Fix parallel mission history preservation

Load existing mission history into runner before starting parallel
execution to prevent losing conversation context.

* Fix desktop stream controls layout overflow on iPad

- Add frame(maxWidth: .infinity) constraints to ensure controls stay
  within bounds on wide displays
- Add alignment: .leading to VStacks for consistent layout
- Add Spacer() to buttons row to prevent spreading
- Increase label width to 55 for consistent FPS/Quality alignment
- Add alignment: .trailing to value text frames

* Fix queued user messages not persisted to mission history

When a user message was queued (sent while another task was running),
it was not being added to the history or persisted to the database.
This caused queued messages to be lost from mission history.

Added the same persistence logic used for initial messages to the
queued message handling code path.
2026-01-04 13:04:05 -08:00

336 lines
13 KiB
Swift

//
// ContentView.swift
// OpenAgentDashboard
//
// Main content view with authentication gate and tab navigation
//
import SwiftUI
struct ContentView: View {
@State private var isAuthenticated = false
@State private var isCheckingAuth = true
@State private var authRequired = false
private let api = APIService.shared
var body: some View {
Group {
if isCheckingAuth {
LoadingView(message: "Connecting...")
.background(Theme.backgroundPrimary.ignoresSafeArea())
} else if authRequired && !isAuthenticated {
LoginView(onLogin: { isAuthenticated = true })
} else {
MainTabView()
}
}
.task {
await checkAuth()
}
}
private func checkAuth() async {
isCheckingAuth = true
do {
let _ = try await api.checkHealth()
authRequired = api.authRequired
isAuthenticated = api.isAuthenticated || !authRequired
} catch {
// If health check fails, assume we need auth
authRequired = true
isAuthenticated = api.isAuthenticated
}
isCheckingAuth = false
}
}
// MARK: - Login View
struct LoginView: View {
let onLogin: () -> Void
@State private var username = ""
@State private var password = ""
@State private var isLoading = false
@State private var errorMessage: String?
@State private var serverURL: String
@FocusState private var isUsernameFocused: Bool
@FocusState private var isPasswordFocused: Bool
private let api = APIService.shared
init(onLogin: @escaping () -> Void) {
self.onLogin = onLogin
_serverURL = State(initialValue: APIService.shared.baseURL)
_username = State(initialValue: UserDefaults.standard.string(forKey: "last_username") ?? "")
}
var body: some View {
ZStack {
// Background
Theme.backgroundPrimary.ignoresSafeArea()
// Gradient accents
RadialGradient(
colors: [Theme.accent.opacity(0.15), .clear],
center: .topTrailing,
startRadius: 50,
endRadius: 400
)
.ignoresSafeArea()
RadialGradient(
colors: [Color.purple.opacity(0.1), .clear],
center: .bottomLeading,
startRadius: 50,
endRadius: 400
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 32) {
Spacer()
.frame(height: 60)
// Logo
VStack(spacing: 16) {
Image(systemName: "brain")
.font(.system(size: 72, weight: .light))
.foregroundStyle(Theme.accent)
.symbolEffect(.pulse, options: .repeating)
VStack(spacing: 4) {
Text("Open Agent")
.font(.largeTitle.bold())
.foregroundStyle(Theme.textPrimary)
Text("Dashboard")
.font(.title3)
.foregroundStyle(Theme.textSecondary)
}
}
// Login form
GlassCard(padding: 24, cornerRadius: 28) {
VStack(spacing: 20) {
// Server URL field
VStack(alignment: .leading, spacing: 8) {
Text("Server URL")
.font(.caption.weight(.medium))
.foregroundStyle(Theme.textSecondary)
TextField("https://agent-backend.example.com", text: $serverURL)
.textFieldStyle(.plain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Theme.border, lineWidth: 1)
)
}
if api.authMode == .multiUser {
VStack(alignment: .leading, spacing: 8) {
Text("Username")
.font(.caption.weight(.medium))
.foregroundStyle(Theme.textSecondary)
TextField("Enter username", text: $username)
.textFieldStyle(.plain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($isUsernameFocused)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(isUsernameFocused ? Theme.accent.opacity(0.5) : Theme.border, lineWidth: 1)
)
}
}
// Password field
VStack(alignment: .leading, spacing: 8) {
Text("Password")
.font(.caption.weight(.medium))
.foregroundStyle(Theme.textSecondary)
SecureField("Enter password", text: $password)
.textFieldStyle(.plain)
.focused($isPasswordFocused)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(isPasswordFocused ? Theme.accent.opacity(0.5) : Theme.border, lineWidth: 1)
)
.onSubmit {
login()
}
}
// Error message
if let error = errorMessage {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(Theme.error)
Text(error)
.font(.caption)
.foregroundStyle(Theme.error)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// Login button
GlassPrimaryButton(
"Sign In",
icon: "arrow.right",
isLoading: isLoading,
isDisabled: password.isEmpty || (api.authMode == .multiUser && username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
) {
login()
}
}
}
.padding(.horizontal, 24)
.onAppear {
if api.authMode == .multiUser {
isUsernameFocused = true
} else {
isPasswordFocused = true
}
}
.onChange(of: api.authMode) { _, newMode in
if newMode == .multiUser {
isUsernameFocused = true
} else {
isPasswordFocused = true
}
}
Spacer()
}
}
}
}
private func login() {
guard !password.isEmpty else { return }
// Update server URL
api.baseURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
isLoading = true
errorMessage = nil
Task {
do {
let usernameValue = api.authMode == .multiUser ? username : nil
let _ = try await api.login(password: password, username: usernameValue)
if api.authMode == .multiUser {
let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
UserDefaults.standard.set(trimmed, forKey: "last_username")
}
}
HapticService.success()
onLogin()
} catch {
if let apiError = error as? APIError {
switch apiError {
case .httpError(let code, _):
if code == 401 {
errorMessage = api.authMode == .multiUser ? "Invalid username or password" : "Invalid password"
} else {
errorMessage = apiError.errorDescription
}
case .unauthorized:
errorMessage = api.authMode == .multiUser ? "Invalid username or password" : "Invalid password"
default:
errorMessage = apiError.errorDescription
}
} else {
errorMessage = error.localizedDescription
}
HapticService.error()
}
isLoading = false
}
}
}
// MARK: - Main Tab View
struct MainTabView: View {
private var nav = NavigationState.shared
enum TabItem: String, CaseIterable {
case control = "Control"
case history = "History"
case terminal = "Terminal"
case files = "Files"
var icon: String {
switch self {
case .control: return "message.fill"
case .history: return "clock.fill"
case .terminal: return "terminal.fill"
case .files: return "folder.fill"
}
}
}
var body: some View {
TabView(selection: Binding(
get: { nav.selectedTab },
set: { nav.selectedTab = $0 }
)) {
ForEach(TabItem.allCases, id: \.rawValue) { tab in
NavigationStack {
tabContent(for: tab)
}
.tabItem {
Label(tab.rawValue, systemImage: tab.icon)
}
.tag(tab)
}
}
.tint(Theme.accent)
}
@ViewBuilder
private func tabContent(for tab: TabItem) -> some View {
switch tab {
case .control:
ControlView()
case .history:
HistoryView()
case .terminal:
TerminalView()
case .files:
FilesView()
}
}
}
#Preview("Login") {
LoginView(onLogin: {})
}
#Preview("Main") {
MainTabView()
}