- Expose End conversation + readable draft during connecting/waiting - Stop masking assistant speaking/thinking state in Type/muted modes - Restrict failure diagnosis to real causes (no invented network text) - Replace old blue tint with Rosewood everywhere - Fix transcript short-content bottom-anchor (top-align short histories) - Settings: visible Server URL label, focus on rejected save, edit clears saved confirmation, trim-aware unchanged detection - Task detail: live store lookup with missing-task state - Task rows: status text label restored
133 lines
5.7 KiB
Swift
133 lines
5.7 KiB
Swift
import SwiftUI
|
||
|
||
/// Nearlight transcript: a legible shared record. Full-width rows, no
|
||
/// bubbles; metadata row per event; "Live" placeholder on partials.
|
||
struct TranscriptListView: View {
|
||
let events: [TranscriptEvent]
|
||
var onClear: () -> Void
|
||
@State private var confirmClear = false
|
||
@State private var followingLatest = true
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@Environment(\.dynamicTypeSize) private var typeSize
|
||
|
||
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(NearlightFont.support(typeSize).weight(.semibold))
|
||
.foregroundStyle(Nearlight.quiet)
|
||
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, Nearlight.s20)
|
||
ScrollView {
|
||
LazyVStack(alignment: .leading, spacing: Nearlight.s24) {
|
||
if events.isEmpty {
|
||
Text("Your conversation will appear here.")
|
||
.font(.body)
|
||
.foregroundStyle(Nearlight.quiet)
|
||
.padding(.top, Nearlight.s24)
|
||
}
|
||
ForEach(events) { event in transcriptRow(event) }
|
||
Color.clear.frame(height: 1).id("latest")
|
||
.onAppear { followingLatest = true }
|
||
.onDisappear { followingLatest = false }
|
||
}
|
||
.padding(.horizontal, Nearlight.s20)
|
||
.padding(.top, Nearlight.s16)
|
||
.padding(.bottom, Nearlight.s20)
|
||
.frame(minHeight: 200, alignment: .topLeading)
|
||
}
|
||
.scrollDismissesKeyboard(.interactively)
|
||
.defaultScrollAnchor(.top)
|
||
.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 : Nearlight.standard) {
|
||
proxy.scrollTo("latest", anchor: .bottom)
|
||
}
|
||
} label: {
|
||
Label("Latest messages", systemImage: "arrow.down")
|
||
.font(NearlightFont.support(typeSize))
|
||
.frame(minHeight: 44)
|
||
}
|
||
.accessibilityIdentifier("talk.latest")
|
||
}
|
||
}
|
||
}
|
||
.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 VStack(alignment: .leading, spacing: Nearlight.s8) {
|
||
// Metadata row: speaker, timestamp trailing. Stacks at
|
||
// accessibility sizes per spec.
|
||
Group {
|
||
if typeSize.isAccessibilitySize {
|
||
VStack(alignment: .leading, spacing: Nearlight.s4) {
|
||
speakerLabel(isUser)
|
||
timeLabel(event)
|
||
}
|
||
} else {
|
||
HStack(spacing: Nearlight.s8) {
|
||
speakerLabel(isUser)
|
||
timeLabel(event)
|
||
}
|
||
}
|
||
}
|
||
Text(text)
|
||
.font(.body)
|
||
.foregroundStyle(Nearlight.ink)
|
||
.textSelection(.enabled)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contextMenu {
|
||
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
.id(event.id)
|
||
}
|
||
|
||
private func speakerLabel(_ isUser: Bool) -> some View {
|
||
Text(isUser ? "You" : "Hope")
|
||
.font(NearlightFont.metadata(typeSize).weight(.semibold))
|
||
.foregroundStyle(Nearlight.ink)
|
||
}
|
||
|
||
private func timeLabel(_ event: TranscriptEvent) -> some View {
|
||
Group {
|
||
if event.isFinal {
|
||
Text(event.timestamp, style: .time)
|
||
} else {
|
||
Text("Live")
|
||
}
|
||
}
|
||
.font(NearlightFont.metadata(typeSize))
|
||
.foregroundStyle(Nearlight.quiet)
|
||
.monospacedDigit()
|
||
}
|
||
}
|