// // ConversationView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The center conversation column (max 760pt): the task's persisted history // (user bubbles + past runs' step cards + Markdown final answers) followed // by the live run's streaming timeline — step cards, compaction notices, the // blocking approval card, and the LoopGuard pause card. Auto-scrolls while // pinned to the bottom; scrolling up unpins and shows a jump-to-latest pill. // import SwiftUI struct ConversationView: View { let task: AgentTask @ObservedObject var controller: RunController @EnvironmentObject private var appearance: AppearanceStore @State private var pinnedToBottom = true private static let bottomID = "conversation-bottom" private static let scrollSpace = "conversation-scroll" /// Distance from the bottom (pt) still counted as "pinned". private static let pinThreshold: CGFloat = 60 var body: some View { GeometryReader { viewport in ScrollViewReader { proxy in ZStack(alignment: .bottom) { ScrollView { LazyVStack(alignment: .leading, spacing: ZyquoMetrics.verticalTurnRhythm) { history liveRun bottomMarker } .padding(.horizontal, ZyquoMetrics.contentInset) .padding(.vertical, ZyquoMetrics.contentInset) .frame(maxWidth: ZyquoMetrics.maxMessageColumnWidth) .frame(maxWidth: .infinity) } .coordinateSpace(name: Self.scrollSpace) .onPreferenceChange(BottomMarkerPreferenceKey.self) { markerY in // Marker position in the viewport's coordinate space: // beyond the visible height ⇒ the user scrolled up. pinnedToBottom = markerY < viewport.size.height + Self.pinThreshold } .onChange(of: contentFingerprint) { _ in if pinnedToBottom { proxy.scrollTo(Self.bottomID, anchor: .bottom) } } if !pinnedToBottom, controller.isRunning { jumpToBottomPill(proxy: proxy) } } } } } // MARK: - History (persisted runs) @ViewBuilder private var history: some View { ForEach(task.messages) { message in switch message.kind { case .user: UserBubbleView(text: message.text, fontSize: appearance.chatFontSize) case .agentRun: agentRunHistory(message) } } } @ViewBuilder private func agentRunHistory(_ message: TaskMessage) -> some View { let steps = historySteps(of: message) ForEach(steps, id: \.id) { step in StepCardView(step: LiveStep(step: step), fontSize: appearance.chatFontSize) } outcomeView(for: message) } /// Past-run steps to render as cards — the trailing final-answer step is /// folded into the answer bubble instead of duplicating. private func historySteps(of message: TaskMessage) -> [AgentStep] { var steps = message.steps ?? [] if let last = steps.last, last.isFinal { steps.removeLast() } return steps } @ViewBuilder private func outcomeView(for message: TaskMessage) -> some View { switch message.outcome { case .completed: AgentAnswerBubbleView( text: message.text, provider: task.providerID, fontSize: appearance.chatFontSize ) case .failed(let reason): RunNoticeView(symbol: "xmark.circle.fill", text: reason, color: ZyquoColor.danger) case .cancelled: RunNoticeView(symbol: "slash.circle", text: "Run cancelled.", color: ZyquoColor.textTertiary) case .stoppedByUser(let reason): RunNoticeView(symbol: "stop.circle", text: "Run stopped — \(reason)", color: ZyquoColor.textTertiary) case nil: EmptyView() } } // MARK: - Live run @ViewBuilder private var liveRun: some View { ForEach(controller.entries) { entry in switch entry { case .step(let step): StepCardView(step: step, fontSize: appearance.chatFontSize, isLive: controller.isRunning) case .compaction(let record): CompactionNoticeView(record: record) } } if let approval = controller.pendingApproval { ApprovalCardView(approval: approval) { resolution in withAnimation(ZyquoMotion.appear) { controller.resolveApproval(resolution) } } } if let trip = controller.guardTrip { GuardTripCardView( trip: trip, onContinue: { controller.resumeAfterTrip(raisingBudget: true) }, onStop: { controller.stopAfterTrip() } ) } if let error = controller.lastError { RunNoticeView(symbol: "exclamationmark.triangle.fill", text: error, color: ZyquoColor.danger) } } // MARK: - Scrolling private var bottomMarker: some View { GeometryReader { geometry in Color.clear.preference( key: BottomMarkerPreferenceKey.self, value: geometry.frame(in: .named(Self.scrollSpace)).minY ) } .frame(height: 1) .id(Self.bottomID) } /// Changes when streamed content grows so auto-scroll can follow. private var contentFingerprint: Int { var fingerprint = task.messages.count &* 31 &+ controller.entries.count &* 7 if case .step(let step)? = controller.entries.last { fingerprint &+= step.text.count &+ step.thinking.count fingerprint &+= step.invocations.reduce(0) { $0 &+ $1.argumentsJSON.count &+ $1.outputLines.count } } if controller.pendingApproval != nil { fingerprint &+= 1 } if controller.guardTrip != nil { fingerprint &+= 3 } return fingerprint } private func jumpToBottomPill(proxy: ScrollViewProxy) -> some View { Button { pinnedToBottom = true withAnimation(ZyquoMotion.appear) { proxy.scrollTo(Self.bottomID, anchor: .bottom) } } label: { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "arrow.down") .font(.system(size: 10, weight: .semibold)) Text("Jump to latest") .font(ZyquoFont.caption) } .foregroundStyle(ZyquoColor.textPrimary) .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, 5) .background(Capsule().fill(ZyquoColor.surface)) .overlay(Capsule().strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)) .zyquoSoftShadow() } .buttonStyle(PressableButtonStyle()) .padding(.bottom, ZyquoSpacing.xs) } } /// Y position of the transcript's bottom marker in the scroll viewport space. private struct BottomMarkerPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } // MARK: - Bubbles & notices /// User prompt: right-aligned accent-subtle bubble. struct UserBubbleView: View { let text: String let fontSize: Double var body: some View { HStack(alignment: .top, spacing: ZyquoSpacing.xs) { Spacer(minLength: 60) Text(text) .font(ZyquoFont.body(size: fontSize)) .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor) .foregroundStyle(ZyquoColor.textPrimary) .textSelection(.enabled) .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xs + 2) .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(ZyquoColor.accentSubtle) ) } } } /// Final answer: left-aligned surface bubble with provider avatar + Markdown. struct AgentAnswerBubbleView: View { let text: String let provider: ProviderID let fontSize: Double var body: some View { HStack(alignment: .top, spacing: ZyquoSpacing.xs) { Image(systemName: provider.symbolName) .font(.system(size: 12, weight: .medium)) .foregroundStyle(ZyquoColor.accent) .frame(width: 26, height: 26) .background(Circle().fill(ZyquoColor.accentSubtle)) .padding(.top, 2) MarkdownView(text: text, fontSize: fontSize) .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xs + 2) .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(ZyquoColor.surface) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) ) ) Spacer(minLength: 60) } } } /// Inline run-lifecycle notice (failure / cancelled / stopped / error). struct RunNoticeView: View { let symbol: String let text: String let color: Color var body: some View { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: symbol) .font(.system(size: 11)) Text(text) .font(ZyquoFont.body(size: 12.5)) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } .foregroundStyle(color) .padding(ZyquoSpacing.xs) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(color.opacity(0.08)) ) } }