SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
10.3 KB · 282 lines swift
Raw Blame History
1//2//  ConversationView.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The center conversation column (max 760pt): the task's persisted history9//  (user bubbles + past runs' step cards + Markdown final answers) followed10//  by the live run's streaming timeline — step cards, compaction notices, the11//  blocking approval card, and the LoopGuard pause card. Auto-scrolls while12//  pinned to the bottom; scrolling up unpins and shows a jump-to-latest pill.13//1415import SwiftUI1617struct ConversationView: View {18    let task: AgentTask19    @ObservedObject var controller: RunController2021    @EnvironmentObject private var appearance: AppearanceStore22    @State private var pinnedToBottom = true2324    private static let bottomID = "conversation-bottom"25    private static let scrollSpace = "conversation-scroll"26    /// Distance from the bottom (pt) still counted as "pinned".27    private static let pinThreshold: CGFloat = 602829    var body: some View {30        GeometryReader { viewport in31            ScrollViewReader { proxy in32                ZStack(alignment: .bottom) {33                    ScrollView {34                        LazyVStack(alignment: .leading, spacing: ZyquoMetrics.verticalTurnRhythm) {35                            history36                            liveRun37                            bottomMarker38                        }39                        .padding(.horizontal, ZyquoMetrics.contentInset)40                        .padding(.vertical, ZyquoMetrics.contentInset)41                        .frame(maxWidth: ZyquoMetrics.maxMessageColumnWidth)42                        .frame(maxWidth: .infinity)43                    }44                    .coordinateSpace(name: Self.scrollSpace)45                    .onPreferenceChange(BottomMarkerPreferenceKey.self) { markerY in46                        // Marker position in the viewport's coordinate space:47                        // beyond the visible height ⇒ the user scrolled up.48                        pinnedToBottom = markerY < viewport.size.height + Self.pinThreshold49                    }50                    .onChange(of: contentFingerprint) { _ in51                        if pinnedToBottom {52                            proxy.scrollTo(Self.bottomID, anchor: .bottom)53                        }54                    }55                    if !pinnedToBottom, controller.isRunning {56                        jumpToBottomPill(proxy: proxy)57                    }58                }59            }60        }61    }6263    // MARK: - History (persisted runs)6465    @ViewBuilder66    private var history: some View {67        ForEach(task.messages) { message in68            switch message.kind {69            case .user:70                UserBubbleView(text: message.text, fontSize: appearance.chatFontSize)71            case .agentRun:72                agentRunHistory(message)73            }74        }75    }7677    @ViewBuilder78    private func agentRunHistory(_ message: TaskMessage) -> some View {79        let steps = historySteps(of: message)80        ForEach(steps, id: \.id) { step in81            StepCardView(step: LiveStep(step: step), fontSize: appearance.chatFontSize)82        }83        outcomeView(for: message)84    }8586    /// Past-run steps to render as cards — the trailing final-answer step is87    /// folded into the answer bubble instead of duplicating.88    private func historySteps(of message: TaskMessage) -> [AgentStep] {89        var steps = message.steps ?? []90        if let last = steps.last, last.isFinal {91            steps.removeLast()92        }93        return steps94    }9596    @ViewBuilder97    private func outcomeView(for message: TaskMessage) -> some View {98        switch message.outcome {99        case .completed:100            AgentAnswerBubbleView(101                text: message.text,102                provider: task.providerID,103                fontSize: appearance.chatFontSize104            )105        case .failed(let reason):106            RunNoticeView(symbol: "xmark.circle.fill", text: reason, color: ZyquoColor.danger)107        case .cancelled:108            RunNoticeView(symbol: "slash.circle", text: "Run cancelled.", color: ZyquoColor.textTertiary)109        case .stoppedByUser(let reason):110            RunNoticeView(symbol: "stop.circle", text: "Run stopped — \(reason)", color: ZyquoColor.textTertiary)111        case nil:112            EmptyView()113        }114    }115116    // MARK: - Live run117118    @ViewBuilder119    private var liveRun: some View {120        ForEach(controller.entries) { entry in121            switch entry {122            case .step(let step):123                StepCardView(step: step, fontSize: appearance.chatFontSize, isLive: controller.isRunning)124            case .compaction(let record):125                CompactionNoticeView(record: record)126            }127        }128        if let approval = controller.pendingApproval {129            ApprovalCardView(approval: approval) { resolution in130                withAnimation(ZyquoMotion.appear) {131                    controller.resolveApproval(resolution)132                }133            }134        }135        if let trip = controller.guardTrip {136            GuardTripCardView(137                trip: trip,138                onContinue: { controller.resumeAfterTrip(raisingBudget: true) },139                onStop: { controller.stopAfterTrip() }140            )141        }142        if let error = controller.lastError {143            RunNoticeView(symbol: "exclamationmark.triangle.fill", text: error, color: ZyquoColor.danger)144        }145    }146147    // MARK: - Scrolling148149    private var bottomMarker: some View {150        GeometryReader { geometry in151            Color.clear.preference(152                key: BottomMarkerPreferenceKey.self,153                value: geometry.frame(in: .named(Self.scrollSpace)).minY154            )155        }156        .frame(height: 1)157        .id(Self.bottomID)158    }159160    /// Changes when streamed content grows so auto-scroll can follow.161    private var contentFingerprint: Int {162        var fingerprint = task.messages.count &* 31 &+ controller.entries.count &* 7163        if case .step(let step)? = controller.entries.last {164            fingerprint &+= step.text.count &+ step.thinking.count165            fingerprint &+= step.invocations.reduce(0) { $0 &+ $1.argumentsJSON.count &+ $1.outputLines.count }166        }167        if controller.pendingApproval != nil { fingerprint &+= 1 }168        if controller.guardTrip != nil { fingerprint &+= 3 }169        return fingerprint170    }171172    private func jumpToBottomPill(proxy: ScrollViewProxy) -> some View {173        Button {174            pinnedToBottom = true175            withAnimation(ZyquoMotion.appear) { proxy.scrollTo(Self.bottomID, anchor: .bottom) }176        } label: {177            HStack(spacing: ZyquoSpacing.xxs) {178                Image(systemName: "arrow.down")179                    .font(.system(size: 10, weight: .semibold))180                Text("Jump to latest")181                    .font(ZyquoFont.caption)182            }183            .foregroundStyle(ZyquoColor.textPrimary)184            .padding(.horizontal, ZyquoSpacing.sm)185            .padding(.vertical, 5)186            .background(Capsule().fill(ZyquoColor.surface))187            .overlay(Capsule().strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline))188            .zyquoSoftShadow()189        }190        .buttonStyle(PressableButtonStyle())191        .padding(.bottom, ZyquoSpacing.xs)192    }193}194195/// Y position of the transcript's bottom marker in the scroll viewport space.196private struct BottomMarkerPreferenceKey: PreferenceKey {197    static var defaultValue: CGFloat = 0198    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {199        value = nextValue()200    }201}202203// MARK: - Bubbles & notices204205/// User prompt: right-aligned accent-subtle bubble.206struct UserBubbleView: View {207    let text: String208    let fontSize: Double209210    var body: some View {211        HStack(alignment: .top, spacing: ZyquoSpacing.xs) {212            Spacer(minLength: 60)213            Text(text)214                .font(ZyquoFont.body(size: fontSize))215                .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor)216                .foregroundStyle(ZyquoColor.textPrimary)217                .textSelection(.enabled)218                .padding(.horizontal, ZyquoSpacing.sm)219                .padding(.vertical, ZyquoSpacing.xs + 2)220                .background(221                    RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)222                        .fill(ZyquoColor.accentSubtle)223                )224        }225    }226}227228/// Final answer: left-aligned surface bubble with provider avatar + Markdown.229struct AgentAnswerBubbleView: View {230    let text: String231    let provider: ProviderID232    let fontSize: Double233234    var body: some View {235        HStack(alignment: .top, spacing: ZyquoSpacing.xs) {236            Image(systemName: provider.symbolName)237                .font(.system(size: 12, weight: .medium))238                .foregroundStyle(ZyquoColor.accent)239                .frame(width: 26, height: 26)240                .background(Circle().fill(ZyquoColor.accentSubtle))241                .padding(.top, 2)242            MarkdownView(text: text, fontSize: fontSize)243                .padding(.horizontal, ZyquoSpacing.sm)244                .padding(.vertical, ZyquoSpacing.xs + 2)245                .background(246                    RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)247                        .fill(ZyquoColor.surface)248                        .overlay(249                            RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)250                                .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)251                        )252                )253            Spacer(minLength: 60)254        }255    }256}257258/// Inline run-lifecycle notice (failure / cancelled / stopped / error).259struct RunNoticeView: View {260    let symbol: String261    let text: String262    let color: Color263264    var body: some View {265        HStack(spacing: ZyquoSpacing.xxs) {266            Image(systemName: symbol)267                .font(.system(size: 11))268            Text(text)269                .font(ZyquoFont.body(size: 12.5))270                .textSelection(.enabled)271                .fixedSize(horizontal: false, vertical: true)272        }273        .foregroundStyle(color)274        .padding(ZyquoSpacing.xs)275        .frame(maxWidth: .infinity, alignment: .leading)276        .background(277            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)278                .fill(color.opacity(0.08))279        )280    }281}282