← back to Exo

app/EXO/EXO/Views/InstanceRowView.swift

338 lines

import SwiftUI

struct InstanceRowView: View {
    let instance: InstanceViewModel
    @State private var animatedTaskIDs: Set<String> = []
    @State private var infoTask: InstanceTaskViewModel?
    @State private var showChatTasks = true

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(spacing: 8) {
                VStack(alignment: .leading, spacing: 2) {
                    Text(instance.modelName)
                        .font(.subheadline)
                    Text(instance.nodeSummary)
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                Spacer()
                if let progress = instance.downloadProgress {
                    downloadStatusView(progress: progress)
                } else {
                    statusChip(label: instance.state.label.uppercased(), color: statusColor)
                }
            }
            if let progress = instance.downloadProgress {
                GeometryReader { geometry in
                    HStack {
                        Spacer()
                        downloadProgressBar(progress: progress)
                            .frame(width: geometry.size.width * 0.5)
                    }
                }
                .frame(height: 4)
                .padding(.top, -8)
                .padding(.bottom, 2)
                HStack(spacing: 8) {
                    Text(instance.sharding ?? "")
                        .font(.caption2)
                        .foregroundColor(.secondary)
                    Spacer()
                    downloadSpeedView(progress: progress)
                }
            } else {
                Text(instance.sharding ?? "")
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }
            if !instance.chatTasks.isEmpty {
                VStack(alignment: .leading, spacing: 4) {
                    HStack {
                        Text("Chat Tasks")
                            .font(.caption2)
                            .foregroundColor(.secondary)
                        Text("(\(instance.chatTasks.count))")
                            .font(.caption2)
                            .foregroundColor(.secondary)
                        Spacer()
                        collapseButton(isExpanded: $showChatTasks)
                    }
                    .animation(nil, value: showChatTasks)
                    if showChatTasks {
                        VStack(alignment: .leading, spacing: 8) {
                            ForEach(instance.chatTasks) { task in
                                taskRow(for: task, parentModelName: instance.modelName)
                            }
                        }
                        .transition(.opacity)
                    }
                }
                .padding(.top, 4)
                .animation(.easeInOut(duration: 0.25), value: showChatTasks)
            }
        }
        .padding(.vertical, 6)
    }

    private var statusColor: Color {
        switch instance.state {
        case .downloading: return .blue
        case .warmingUp: return .orange
        case .running: return .green
        case .ready: return .teal
        case .waiting, .idle: return .gray
        case .failed: return .red
        case .preparing: return .secondary
        }
    }

    @ViewBuilder
    private func taskRow(for task: InstanceTaskViewModel, parentModelName: String) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack(alignment: .top, spacing: 8) {
                taskStatusIcon(for: task)
                VStack(alignment: .leading, spacing: 2) {
                    Text("Chat")
                        .font(.caption)
                        .fontWeight(.semibold)
                    if let subtitle = task.subtitle,
                        subtitle.caseInsensitiveCompare(parentModelName) != .orderedSame
                    {
                        Text(subtitle)
                            .font(.caption2)
                            .foregroundColor(.secondary)
                    }
                    if let prompt = task.promptPreview, !prompt.isEmpty {
                        Text("⊙ \(prompt)")
                            .font(.caption2)
                            .foregroundColor(.secondary)
                            .lineLimit(2)
                    }
                    if task.status == .failed, let error = task.errorMessage, !error.isEmpty {
                        Text(error)
                            .font(.caption2)
                            .foregroundColor(.red)
                            .lineLimit(3)
                    }
                }
                Spacer(minLength: 6)
                Button {
                    infoTask = task
                } label: {
                    Image(systemName: "info.circle")
                        .imageScale(.small)
                }
                .buttonStyle(.plain)
                .popover(
                    item: Binding<InstanceTaskViewModel?>(
                        get: { infoTask?.id == task.id ? infoTask : nil },
                        set: { newValue in
                            if newValue == nil {
                                infoTask = nil
                            } else {
                                infoTask = newValue
                            }
                        }
                    ),
                    attachmentAnchor: .rect(.bounds),
                    arrowEdge: .top
                ) { _ in
                    TaskDetailView(task: task)
                        .padding()
                        .frame(width: 240)
                }
            }
        }
    }

    private func taskStatusIcon(for task: InstanceTaskViewModel) -> some View {
        let icon: String
        let color: Color
        let animation: Animation?

        switch task.status {
        case .running:
            icon = "arrow.triangle.2.circlepath"
            color = .blue
            animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
        case .pending:
            icon = "circle.dashed"
            color = .secondary
            animation = nil
        case .failed:
            icon = "exclamationmark.triangle.fill"
            color = .red
            animation = nil
        case .complete:
            icon = "checkmark.circle.fill"
            color = .green
            animation = nil
        case .unknown:
            icon = "questionmark.circle"
            color = .secondary
            animation = nil
        }

        let image = Image(systemName: icon)
            .imageScale(.small)
            .foregroundColor(color)

        if let animation {
            return AnyView(
                image
                    .rotationEffect(.degrees(animatedTaskIDs.contains(task.id) ? 360 : 0))
                    .onAppear {
                        if !animatedTaskIDs.contains(task.id) {
                            animatedTaskIDs.insert(task.id)
                        }
                    }
                    .animation(animation, value: animatedTaskIDs)
            )
        }

        return AnyView(image)
    }

    private func statusChip(label: String, color: Color) -> some View {
        Text(label)
            .font(.caption2)
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(color.opacity(0.15))
            .foregroundColor(color)
            .clipShape(Capsule())
    }

    private func downloadStatusView(progress: DownloadProgressViewModel) -> some View {
        VStack(alignment: .trailing, spacing: 4) {
            statusChip(label: "DOWNLOADING", color: .blue)
            Text(progress.formattedProgress)
                .foregroundColor(.primary)
        }
        .font(.caption2)
    }

    private func downloadSpeedView(progress: DownloadProgressViewModel) -> some View {
        HStack(spacing: 4) {
            Text(progress.formattedSpeed)
            if let eta = progress.formattedETA {
                Text("·")
                Text(eta)
            }
        }
        .font(.caption2)
        .foregroundColor(.secondary)
    }

    private func downloadProgressBar(progress: DownloadProgressViewModel) -> some View {
        ProgressView(value: progress.fractionCompleted)
            .progressViewStyle(.linear)
            .tint(.blue)
    }

    private func collapseButton(isExpanded: Binding<Bool>) -> some View {
        Button {
            isExpanded.wrappedValue.toggle()
        } label: {
            Label(
                isExpanded.wrappedValue ? "Hide" : "Show",
                systemImage: isExpanded.wrappedValue ? "chevron.up" : "chevron.down"
            )
            .labelStyle(.titleAndIcon)
            .contentTransition(.symbolEffect(.replace))
        }
        .buttonStyle(.plain)
        .font(.caption2)
    }

    private struct TaskDetailView: View, Identifiable {
        let task: InstanceTaskViewModel
        var id: String { task.id }

        var body: some View {
            ScrollView {
                VStack(alignment: .leading, spacing: 12) {
                    parameterSection
                    messageSection
                    if let error = task.errorMessage, !error.isEmpty {
                        detailRow(
                            icon: "exclamationmark.triangle.fill",
                            title: "Error",
                            value: error,
                            tint: .red
                        )
                    }
                }
            }
        }

        @ViewBuilder
        private var parameterSection: some View {
            if let params = task.parameters {
                VStack(alignment: .leading, spacing: 6) {
                    Text("Parameters")
                        .font(.subheadline)
                    if let temperature = params.temperature {
                        detailRow(title: "Temperature", value: String(format: "%.1f", temperature))
                    }
                    if let maxTokens = params.maxTokens {
                        detailRow(title: "Max Tokens", value: "\(maxTokens)")
                    }
                    if let stream = params.stream {
                        detailRow(title: "Stream", value: stream ? "On" : "Off")
                    }
                    if let topP = params.topP {
                        detailRow(title: "Top P", value: String(format: "%.2f", topP))
                    }
                }
            }
        }

        @ViewBuilder
        private var messageSection: some View {
            if let messages = task.parameters?.messages, !messages.isEmpty {
                VStack(alignment: .leading, spacing: 6) {
                    Text("Messages")
                        .font(.subheadline)
                    ForEach(Array(messages.enumerated()), id: \.offset) { _, message in
                        VStack(alignment: .leading, spacing: 2) {
                            Text(message.role?.capitalized ?? "Message")
                                .font(.caption)
                                .foregroundColor(.secondary)
                            if let content = message.content, !content.isEmpty {
                                Text(content)
                                    .font(.caption2)
                                    .foregroundColor(.primary)
                            }
                        }
                        .padding(8)
                        .background(Color.secondary.opacity(0.08))
                        .clipShape(RoundedRectangle(cornerRadius: 6))
                    }
                }
            }
        }

        @ViewBuilder
        private func detailRow(
            icon: String? = nil, title: String, value: String, tint: Color = .secondary
        ) -> some View {
            HStack(alignment: .firstTextBaseline, spacing: 6) {
                if let icon {
                    Image(systemName: icon)
                        .imageScale(.small)
                        .foregroundColor(tint)
                }
                Text(title)
                    .font(.caption)
                    .foregroundColor(.secondary)
                Spacer()
                Text(value)
                    .font(.caption2)
                    .foregroundColor(.primary)
            }
        }
    }
}