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%
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