// // TranscriptView.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// Lazy transcript: user right in accentSubtle, assistant left on surface, /// 16 pt rhythm, hover timestamps, streaming tail with caret, jump-to-bottom. struct TranscriptView: View { let conversation: Conversation @Environment(AppModel.self) private var app @State private var atBottom = true private var isStreamingHere: Bool { app.chat.isGenerating && app.chat.streamingConversationID == conversation.id } var body: some View { ScrollViewReader { proxy in ZStack(alignment: .bottom) { ScrollView { LazyVStack(spacing: ZyquoTheme.Spacing.m) { ForEach(conversation.messages.filter { $0.role != .system }) { message in MessageRow(message: message, conversation: conversation) } if isStreamingHere { StreamingRow() } Color.clear .frame(height: 1) .id("bottom") .onAppear { atBottom = true } .onDisappear { atBottom = false } } .padding(.horizontal, ZyquoTheme.Spacing.xl) .padding(.vertical, ZyquoTheme.Spacing.l) .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth) .frame(maxWidth: .infinity) } .onChange(of: app.chat.streamingText) { if atBottom { proxy.scrollTo("bottom", anchor: .bottom) } } .onChange(of: conversation.messages.count) { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo("bottom", anchor: .bottom) } } .onChange(of: conversation.id) { proxy.scrollTo("bottom", anchor: .bottom) } if !atBottom { Button { withAnimation(.snappy(duration: 0.25)) { proxy.scrollTo("bottom", anchor: .bottom) } } label: { Image(systemName: "arrow.down") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(ZyquoTheme.textPrimary) .padding(ZyquoTheme.Spacing.xs) .background(ZyquoTheme.surface, in: Circle()) .overlay(Circle().stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)) .floatingShadow() } .buttonStyle(PressableButtonStyle()) .padding(.bottom, ZyquoTheme.Spacing.m) .transition(.opacity.combined(with: .move(edge: .bottom))) } } } } } /// The in-flight assistant message: live thinking section, streamed text, /// blinking caret at the stream tail. struct StreamingRow: View { @Environment(AppModel.self) private var app @State private var caretVisible = true var body: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { if !app.chat.streamingThinking.isEmpty { ThinkingDisclosure( thinking: app.chat.streamingThinking, isLive: app.chat.isThinking ) } if !app.chat.streamingText.isEmpty || !app.chat.isThinking { HStack(alignment: .bottom, spacing: 2) { MarkdownText(app.chat.streamingText) Rectangle() .fill(ZyquoTheme.accent) .frame(width: 2, height: ZyquoTheme.chatFontSize + 2) .opacity(caretVisible ? 1 : 0.15) .onAppear { withAnimation(.easeInOut(duration: 0.55).repeatForever()) { caretVisible = false } } } } } .padding(ZyquoTheme.Spacing.s) .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth * 0.86, alignment: .leading) Spacer(minLength: ZyquoTheme.Spacing.xxl) } } }