import SwiftUI struct OrbView: View { enum OrbState { case idle, connecting, listening, speaking, thinking, failed } let state: OrbState var body: some View { ZStack { Circle().fill(HopeStyle.tint.opacity(0.10)) Image(systemName: state == .failed ? "wifi.exclamationmark" : "waveform") .font(.largeTitle.weight(.medium)) .foregroundStyle(HopeStyle.tint) } .frame(width: 76, height: 76) .accessibilityLabel("Hope") } } struct TranscriptListView: View { let events: [TranscriptEvent] var onClear: () -> Void @State private var confirmClear = false @State private var followingLatest = true @Environment(\.accessibilityReduceMotion) private var reduceMotion private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"# var body: some View { ScrollViewReader { proxy in VStack(spacing: 0) { HStack { Text("Conversation").font(.subheadline.weight(.semibold)) Spacer() Menu { Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true } } label: { Image(systemName: "ellipsis").frame(width: 44, height: 44) } .accessibilityLabel("Conversation options") .disabled(events.isEmpty) } .padding(.horizontal, 20) ScrollView { LazyVStack(alignment: .leading, spacing: 20) { if events.isEmpty { Text("Your conversation will appear here.") .font(.body).foregroundStyle(.secondary) .padding(.vertical, 32) } ForEach(events) { event in transcriptRow(event) } Color.clear.frame(height: 1).id("latest") .onAppear { followingLatest = true } .onDisappear { followingLatest = false } } .padding(.horizontal, 20).padding(.bottom, 20) } .scrollDismissesKeyboard(.interactively) .defaultScrollAnchor(.bottom) .onChange(of: events.last?.text) { _, _ in if followingLatest { proxy.scrollTo("latest", anchor: .bottom) } } .onChange(of: events.count) { _, _ in if followingLatest { proxy.scrollTo("latest", anchor: .bottom) } } if !followingLatest && !events.isEmpty { Button { withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) { proxy.scrollTo("latest", anchor: .bottom) } } label: { Label("Latest messages", systemImage: "arrow.down").font(.subheadline) .frame(minHeight: 44) } } } } .alert("Clear this conversation?", isPresented: $confirmClear) { Button("Clear conversation", role: .destructive, action: onClear) Button("Cancel", role: .cancel) {} } message: { Text("This removes the transcript saved on this iPhone. It doesn’t reset Hope’s memory or end your current conversation.") } } private func transcriptRow(_ event: TranscriptEvent) -> some View { let isUser = event.role == .user let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression) .trimmingCharacters(in: .whitespaces) return HStack(alignment: .top, spacing: 0) { if isUser { Spacer(minLength: 32) } VStack(alignment: .leading, spacing: 7) { HStack(spacing: 8) { Text(isUser ? "You" : "Hope").font(.subheadline.weight(.semibold)) Text(event.timestamp, style: .time).font(.caption).foregroundStyle(.secondary) if !event.isFinal { Text("Live").font(.caption).foregroundStyle(.secondary) } } Text(text).font(.body).textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } .padding(isUser ? 14 : 0) .frame(maxWidth: .infinity, alignment: .leading) .background(isUser ? Color(.secondarySystemBackground) : .clear, in: RoundedRectangle(cornerRadius: 16)) .contextMenu { Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text } } if !isUser { Spacer(minLength: 16) } } .accessibilityElement(children: .combine) .id(event.id) } }