spb/zyquo-local Public MIT
Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.
Swift 97.2%
Shell 1.8%
Makefile 1%
1//2// TranscriptView.swift3// Zyquo Local4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import SwiftUI1011/// Lazy transcript: user right in accentSubtle, assistant left on surface,12/// 16 pt rhythm, hover timestamps, streaming tail with caret, jump-to-bottom.13struct TranscriptView: View {14 let conversation: Conversation15 @Environment(AppModel.self) private var app16 @State private var atBottom = true1718 private var isStreamingHere: Bool {19 app.chat.isGenerating && app.chat.streamingConversationID == conversation.id20 }2122 var body: some View {23 ScrollViewReader { proxy in24 ZStack(alignment: .bottom) {25 ScrollView {26 LazyVStack(spacing: ZyquoTheme.Spacing.m) {27 ForEach(conversation.messages.filter { $0.role != .system }) { message in28 MessageRow(message: message, conversation: conversation)29 }30 if isStreamingHere {31 StreamingRow()32 }33 Color.clear34 .frame(height: 1)35 .id("bottom")36 .onAppear { atBottom = true }37 .onDisappear { atBottom = false }38 }39 .padding(.horizontal, ZyquoTheme.Spacing.xl)40 .padding(.vertical, ZyquoTheme.Spacing.l)41 .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth)42 .frame(maxWidth: .infinity)43 }44 .onChange(of: app.chat.streamingText) {45 if atBottom {46 proxy.scrollTo("bottom", anchor: .bottom)47 }48 }49 .onChange(of: conversation.messages.count) {50 withAnimation(.easeOut(duration: 0.15)) {51 proxy.scrollTo("bottom", anchor: .bottom)52 }53 }54 .onChange(of: conversation.id) {55 proxy.scrollTo("bottom", anchor: .bottom)56 }5758 if !atBottom {59 Button {60 withAnimation(.snappy(duration: 0.25)) {61 proxy.scrollTo("bottom", anchor: .bottom)62 }63 } label: {64 Image(systemName: "arrow.down")65 .font(.system(size: 12, weight: .semibold))66 .foregroundStyle(ZyquoTheme.textPrimary)67 .padding(ZyquoTheme.Spacing.xs)68 .background(ZyquoTheme.surface, in: Circle())69 .overlay(Circle().stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline))70 .floatingShadow()71 }72 .buttonStyle(PressableButtonStyle())73 .padding(.bottom, ZyquoTheme.Spacing.m)74 .transition(.opacity.combined(with: .move(edge: .bottom)))75 }76 }77 }78 }79}8081/// The in-flight assistant message: live thinking section, streamed text,82/// blinking caret at the stream tail.83struct StreamingRow: View {84 @Environment(AppModel.self) private var app85 @State private var caretVisible = true8687 var body: some View {88 HStack(alignment: .top) {89 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {90 if !app.chat.streamingThinking.isEmpty {91 ThinkingDisclosure(92 thinking: app.chat.streamingThinking,93 isLive: app.chat.isThinking94 )95 }96 if !app.chat.streamingText.isEmpty || !app.chat.isThinking {97 HStack(alignment: .bottom, spacing: 2) {98 MarkdownText(app.chat.streamingText)99 Rectangle()100 .fill(ZyquoTheme.accent)101 .frame(width: 2, height: ZyquoTheme.chatFontSize + 2)102 .opacity(caretVisible ? 1 : 0.15)103 .onAppear {104 withAnimation(.easeInOut(duration: 0.55).repeatForever()) {105 caretVisible = false106 }107 }108 }109 }110 }111 .padding(ZyquoTheme.Spacing.s)112 .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m))113 .overlay(114 RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)115 .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)116 )117 .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth * 0.86, alignment: .leading)118 Spacer(minLength: ZyquoTheme.Spacing.xxl)119 }120 }121}122