Files
openagent/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift
Thomas Marchand bf15c67c96 fix: ios bugs
2025-12-17 13:56:04 +00:00

708 lines
24 KiB
Swift

//
// ControlView.swift
// OpenAgentDashboard
//
// Chat interface for the AI agent with real-time streaming
//
import SwiftUI
struct ControlView: View {
@State private var messages: [ChatMessage] = []
@State private var inputText = ""
@State private var runState: ControlRunState = .idle
@State private var queueLength = 0
@State private var currentMission: Mission?
@State private var isLoading = true
@State private var streamTask: Task<Void, Never>?
@State private var showMissionMenu = false
@FocusState private var isInputFocused: Bool
private let api = APIService.shared
private let nav = NavigationState.shared
var body: some View {
ZStack {
// Background with subtle accent glow
Theme.backgroundPrimary.ignoresSafeArea()
// Subtle radial gradients for liquid glass refraction
backgroundGlows
VStack(spacing: 0) {
// Messages
messagesView
// Input area
inputView
}
}
.navigationTitle(currentMission?.displayTitle ?? "Control")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
VStack(spacing: 2) {
Text(currentMission?.displayTitle ?? "Control")
.font(.headline)
.foregroundStyle(Theme.textPrimary)
HStack(spacing: 4) {
StatusDot(status: runState.statusType, size: 5)
Text(runState.label)
.font(.caption2)
.foregroundStyle(Theme.textSecondary)
if queueLength > 0 {
Text("\(queueLength) queued")
.font(.caption2)
.foregroundStyle(Theme.textTertiary)
}
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
Task { await createNewMission() }
} label: {
Label("New Mission", systemImage: "plus")
}
if let mission = currentMission {
Divider()
Button {
Task { await setMissionStatus(.completed) }
} label: {
Label("Mark Complete", systemImage: "checkmark.circle")
}
Button(role: .destructive) {
Task { await setMissionStatus(.failed) }
} label: {
Label("Mark Failed", systemImage: "xmark.circle")
}
if mission.status != .active {
Button {
Task { await setMissionStatus(.active) }
} label: {
Label("Reactivate", systemImage: "arrow.clockwise")
}
}
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.body)
}
}
}
.task {
// Check if we're being opened with a specific mission from History
if let pendingId = nav.consumePendingMission() {
await loadMission(id: pendingId)
} else {
await loadCurrentMission()
}
startStreaming()
}
.onChange(of: nav.pendingMissionId) { _, newId in
// Handle navigation from History while Control is already visible
if let missionId = newId {
nav.pendingMissionId = nil
Task {
await loadMission(id: missionId)
}
}
}
.onDisappear {
streamTask?.cancel()
}
}
// MARK: - Background
private var backgroundGlows: some View {
ZStack {
RadialGradient(
colors: [Theme.accent.opacity(0.08), .clear],
center: .topTrailing,
startRadius: 20,
endRadius: 400
)
.ignoresSafeArea()
.allowsHitTesting(false)
RadialGradient(
colors: [Color.white.opacity(0.03), .clear],
center: .bottomLeading,
startRadius: 30,
endRadius: 500
)
.ignoresSafeArea()
.allowsHitTesting(false)
}
}
// MARK: - Header (now in toolbar)
private var headerView: some View {
EmptyView() // Moved to navigation bar
}
// MARK: - Messages
private var messagesView: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 16) {
if messages.isEmpty && !isLoading {
emptyStateView
} else if isLoading {
LoadingView(message: "Loading conversation...")
.frame(height: 200)
} else {
ForEach(messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
}
.padding()
}
.onTapGesture {
// Dismiss keyboard when tapping on messages area
isInputFocused = false
}
.onChange(of: messages.count) { _, _ in
if let lastMessage = messages.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
}
private var emptyStateView: some View {
VStack(spacing: 32) {
Spacer()
// Animated brain icon
Image(systemName: "brain")
.font(.system(size: 56, weight: .light))
.foregroundStyle(
LinearGradient(
colors: [Theme.accent, Theme.accent.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.symbolEffect(.pulse, options: .repeating.speed(0.5))
VStack(spacing: 12) {
Text("Ready to Help")
.font(.title2.bold())
.foregroundStyle(Theme.textPrimary)
Text("Send a message to start working\nwith the AI agent")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
// Suggestion chips
VStack(spacing: 10) {
Text("Try asking:")
.font(.caption)
.foregroundStyle(Theme.textMuted)
FlowLayout(spacing: 8) {
suggestionChip("Write a Python script")
suggestionChip("Debug this error")
suggestionChip("Explain this code")
suggestionChip("Create a file")
}
}
.padding(.top, 8)
Spacer()
Spacer()
}
.padding(.horizontal, 32)
}
private func suggestionChip(_ text: String) -> some View {
Button {
inputText = text
isInputFocused = true
} label: {
Text(text)
.font(.caption.weight(.medium))
.foregroundStyle(Theme.textSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Theme.backgroundSecondary)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Theme.border, lineWidth: 1)
)
}
}
// MARK: - Input
private var inputView: some View {
VStack(spacing: 0) {
Divider()
.background(Theme.border)
HStack(alignment: .bottom, spacing: 12) {
// Text input
TextField("Message the agent...", text: $inputText, axis: .vertical)
.textFieldStyle(.plain)
.font(.body)
.lineLimit(1...5)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Theme.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(isInputFocused ? Theme.accent.opacity(0.4) : Theme.border, lineWidth: 1)
)
.focused($isInputFocused)
.submitLabel(.send)
.onSubmit {
sendMessage()
}
// Send/Stop button
Button {
if runState != .idle {
Task { await cancelRun() }
} else {
sendMessage()
}
} label: {
Image(systemName: runState != .idle ? "stop.fill" : "arrow.up")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(
runState != .idle ? Theme.error :
(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Theme.textMuted : Theme.accent)
)
.clipShape(Circle())
}
.disabled(runState == .idle && inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.animation(.easeInOut(duration: 0.15), value: runState)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.thinMaterial)
}
}
// MARK: - Actions
private func loadCurrentMission() async {
isLoading = true
defer { isLoading = false }
do {
if let mission = try await api.getCurrentMission() {
currentMission = mission
messages = mission.history.enumerated().map { index, entry in
ChatMessage(
id: "\(mission.id)-\(index)",
type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil),
content: entry.content
)
}
}
} catch {
print("Failed to load mission: \(error)")
}
}
private func loadMission(id: String) async {
isLoading = true
defer { isLoading = false }
do {
let missions = try await api.listMissions()
if let mission = missions.first(where: { $0.id == id }) {
currentMission = mission
messages = mission.history.enumerated().map { index, entry in
ChatMessage(
id: "\(mission.id)-\(index)",
type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil),
content: entry.content
)
}
HapticService.success()
}
} catch {
print("Failed to load mission: \(error)")
}
}
private func createNewMission() async {
do {
let mission = try await api.createMission()
currentMission = mission
messages = []
HapticService.success()
} catch {
print("Failed to create mission: \(error)")
HapticService.error()
}
}
private func setMissionStatus(_ status: MissionStatus) async {
guard let mission = currentMission else { return }
do {
try await api.setMissionStatus(id: mission.id, status: status)
currentMission?.status = status
HapticService.success()
} catch {
print("Failed to set status: \(error)")
HapticService.error()
}
}
private func sendMessage() {
let content = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
inputText = ""
HapticService.lightTap()
Task {
do {
let _ = try await api.sendMessage(content: content)
} catch {
print("Failed to send message: \(error)")
HapticService.error()
}
}
}
private func cancelRun() async {
do {
try await api.cancelControl()
HapticService.success()
} catch {
print("Failed to cancel: \(error)")
HapticService.error()
}
}
private func startStreaming() {
streamTask = api.streamControl { eventType, data in
Task { @MainActor in
handleStreamEvent(type: eventType, data: data)
}
}
}
private func handleStreamEvent(type: String, data: [String: Any]) {
switch type {
case "status":
if let state = data["state"] as? String {
runState = ControlRunState(rawValue: state) ?? .idle
}
if let queue = data["queue_len"] as? Int {
queueLength = queue
}
case "user_message":
if let content = data["content"] as? String,
let id = data["id"] as? String {
let message = ChatMessage(id: id, type: .user, content: content)
messages.append(message)
}
case "assistant_message":
if let content = data["content"] as? String,
let id = data["id"] as? String {
let success = data["success"] as? Bool ?? true
let costCents = data["cost_cents"] as? Int ?? 0
let model = data["model"] as? String
// Remove any incomplete thinking messages
messages.removeAll { $0.isThinking && !$0.thinkingDone }
let message = ChatMessage(
id: id,
type: .assistant(success: success, costCents: costCents, model: model),
content: content
)
messages.append(message)
}
case "thinking":
if let content = data["content"] as? String {
let done = data["done"] as? Bool ?? false
// Find existing thinking message or create new
if let index = messages.lastIndex(where: { $0.isThinking && !$0.thinkingDone }) {
messages[index].content += "\n\n---\n\n" + content
if done {
messages[index] = ChatMessage(
id: messages[index].id,
type: .thinking(done: true, startTime: Date()),
content: messages[index].content
)
}
} else if !done {
let message = ChatMessage(
id: "thinking-\(Date().timeIntervalSince1970)",
type: .thinking(done: false, startTime: Date()),
content: content
)
messages.append(message)
}
}
case "error":
if let errorMessage = data["message"] as? String {
let message = ChatMessage(
id: "error-\(Date().timeIntervalSince1970)",
type: .error,
content: errorMessage
)
messages.append(message)
}
case "tool_call":
if let toolCallId = data["tool_call_id"] as? String,
let name = data["name"] as? String,
let args = data["args"] as? [String: Any] {
// Parse UI tool calls
if let toolUI = ToolUIContent.parse(name: name, args: args) {
let message = ChatMessage(
id: toolCallId,
type: .toolUI(name: name),
content: "",
toolUI: toolUI
)
messages.append(message)
}
}
default:
break
}
}
}
// MARK: - Message Bubble
private struct MessageBubble: View {
let message: ChatMessage
var body: some View {
HStack(alignment: .top, spacing: 10) {
if message.isUser {
Spacer(minLength: 60)
userBubble
} else if message.isThinking {
thinkingBubble
Spacer(minLength: 60)
} else if message.isToolUI {
toolUIBubble
Spacer(minLength: 40)
} else {
assistantBubble
Spacer(minLength: 60)
}
}
}
@ViewBuilder
private var toolUIBubble: some View {
if let toolUI = message.toolUI {
ToolUIView(content: toolUI)
}
}
private var userBubble: some View {
VStack(alignment: .trailing, spacing: 4) {
Text(message.content)
.font(.body)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Theme.accent)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.clipShape(
.rect(
topLeadingRadius: 20,
bottomLeadingRadius: 20,
bottomTrailingRadius: 6,
topTrailingRadius: 20
)
)
}
}
private var assistantBubble: some View {
VStack(alignment: .leading, spacing: 8) {
// Status header for assistant messages
if case .assistant(let success, _, _) = message.type {
HStack(spacing: 6) {
Image(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.caption2)
.foregroundStyle(success ? Theme.success : Theme.error)
if let model = message.displayModel {
Text(model)
.font(.caption2.monospaced())
.foregroundStyle(Theme.textTertiary)
}
if let cost = message.costFormatted {
Text("")
.foregroundStyle(Theme.textMuted)
Text(cost)
.font(.caption2.monospaced())
.foregroundStyle(Theme.success)
}
}
}
MarkdownText(message.content)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
.clipShape(
.rect(
topLeadingRadius: 20,
bottomLeadingRadius: 6,
bottomTrailingRadius: 20,
topTrailingRadius: 20
)
)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Theme.border, lineWidth: 0.5)
)
}
}
private var thinkingBubble: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "brain")
.font(.caption)
.foregroundStyle(Theme.accent)
.symbolEffect(.pulse, options: message.thinkingDone ? .nonRepeating : .repeating)
Text(message.thinkingDone ? "Thought" : "Thinking...")
.font(.caption)
.foregroundStyle(Theme.textSecondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.accent.opacity(0.1))
.clipShape(Capsule())
if !message.content.isEmpty {
Text(message.content)
.font(.caption)
.foregroundStyle(Theme.textTertiary)
.lineLimit(message.thinkingDone ? 3 : nil)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white.opacity(0.02))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
}
}
// MARK: - Flow Layout
private struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = FlowResult(in: proposal.width ?? 0, spacing: spacing, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = FlowResult(in: bounds.width, spacing: spacing, subviews: subviews)
for (index, subview) in subviews.enumerated() {
subview.place(at: CGPoint(x: bounds.minX + result.positions[index].x,
y: bounds.minY + result.positions[index].y),
proposal: .unspecified)
}
}
struct FlowResult {
var size: CGSize = .zero
var positions: [CGPoint] = []
init(in maxWidth: CGFloat, spacing: CGFloat, subviews: Subviews) {
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
positions.append(CGPoint(x: x, y: y))
rowHeight = max(rowHeight, size.height)
x += size.width + spacing
self.size.width = max(self.size.width, x)
}
self.size.height = y + rowHeight
}
}
}
// MARK: - Markdown Text
private struct MarkdownText: View {
let content: String
init(_ content: String) {
self.content = content
}
var body: some View {
if let attributed = try? AttributedString(markdown: content, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
Text(attributed)
.font(.body)
.foregroundStyle(Theme.textPrimary)
.tint(Theme.accent)
} else {
Text(content)
.font(.body)
.foregroundStyle(Theme.textPrimary)
}
}
}
#Preview {
NavigationStack {
ControlView()
}
}