fix: close Nearlight release blockers

This commit is contained in:
snowspeeder committed 2026-09-12 10:13:14 -04:00
1 parent 5577162594
commit b7cec80a99
7 files changed
+310 -179

No files matched your search

@@ -1,3 +1,4 @@
import AVFoundation
import SwiftUI
/// Owns one session across all tabs. Changing tabs never ends a conversation.
@@ -7,7 +8,6 @@ struct ConversationView: View {
@State private var activeServerURL: String
@State private var pendingServerURL: String?
@State private var isReplacingSession = false
@State private var sessionID = UUID()
@State private var selectedTab = 0
init(settings: SettingsStore) {
@@ -28,6 +28,15 @@ struct ConversationView: View {
session.transcriptHandler.receiveDataTranscript(role: .agent, text: "Let’s leave some room to breathe. A walk, a good book, and time for your favorite music would be a lovely start.")
session.taskStore.injectSampleEvents()
}
if ProcessInfo.processInfo.arguments.contains("--ui-long-transcript") {
for index in 1...30 {
let role: TranscriptEvent.Role = index.isMultiple(of: 2) ? .agent : .user
session.transcriptHandler.receiveDataTranscript(
role: role,
text: "Earlier message \(index): a deliberately long restored transcript row for reading-position verification."
)
}
}
#endif
return session
}
@@ -44,7 +53,6 @@ struct ConversationView: View {
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
.tag(1)
}
.id(sessionID)
.tint(Nearlight.rosewood)
.onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) }
@@ -77,7 +85,6 @@ struct ConversationView: View {
await session.disconnect()
activeServerURL = next
session = Self.makeSession(next, settings: settings)
sessionID = UUID()
}
isReplacingSession = false
}
@@ -290,10 +297,11 @@ private struct ConversationContent: View {
}
private var micDenied: Bool {
// Only the explicit permission-denied wording from enableMicrophone
// counts; other mic errors keep their real message without a
// Settings deep link.
session.state == .ready && (session.micError?.lowercased().contains("denied") ?? false)
guard session.state == .ready, let micError = session.micError else {
return false
}
return AVAudioApplication.shared.recordPermission == .denied
|| micError.localizedCaseInsensitiveContains("denied")
}
private var failureTitle: String {
@@ -543,7 +551,7 @@ private struct ConversationContent: View {
if case .failed(let message) = session.state { return message }
return nil
}
private var failureNotice: String? { failureMessage }
private var failureNotice: String? { session.micError ?? failureMessage }
private var statusTitle: String {
switch session.state {
@@ -8,13 +8,22 @@ struct TranscriptListView: View {
@State private var confirmClear = false
@State private var followingLatest = true
@State private var selectionActive = false
@State private var viewportHeight: CGFloat = 0
@State private var latestBottom: CGFloat = 0
@AccessibilityFocusState private var focusedEventID: String?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var typeSize
init(events: [TranscriptEvent], onClear: @escaping () -> Void) {
self.events = events
self.onClear = onClear
_followingLatest = State(initialValue: events.isEmpty)
}
/// Auto-follow eligibility: never scroll while the user is selecting
/// text or reading history.
private var isFollowingEligible: Bool {
followingLatest && !selectionActive
followingLatest && !selectionActive && focusedEventID == nil
}
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*"#
@@ -44,13 +53,29 @@ struct TranscriptListView: View {
.padding(.top, Nearlight.s24)
}
ForEach(events) { event in transcriptRow(event) }
Color.clear.frame(height: 1).id("latest")
GeometryReader { geometry in
Color.clear.preference(
key: TranscriptLatestBottomKey.self,
value: geometry.frame(in: .named("transcriptScroll")).maxY
)
}
.frame(height: 1)
.id("latest")
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s16)
.padding(.bottom, Nearlight.s20)
.frame(minHeight: 200, alignment: .topLeading)
}
.coordinateSpace(name: "transcriptScroll")
.background {
GeometryReader { geometry in
Color.clear.preference(
key: TranscriptViewportHeightKey.self,
value: geometry.size.height
)
}
}
.scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.top)
.simultaneousGesture(DragGesture().onChanged { _ in
@@ -61,15 +86,32 @@ struct TranscriptListView: View {
guard isFollowingEligible else { return }
proxy.scrollTo("latest", anchor: .bottom)
}
.onChange(of: events.count) { _, _ in
guard isFollowingEligible else { return }
proxy.scrollTo("latest", anchor: .bottom)
.onChange(of: events.count) { _, count in
if count == 0 {
selectionActive = false
followingLatest = true
} else if isFollowingEligible {
proxy.scrollTo("latest", anchor: .bottom)
}
}
.onChange(of: selectionActive) { _, active in
// Beginning text selection suspends auto-follow so the
// user's reading position is never disturbed.
if active { followingLatest = false }
}
.onChange(of: focusedEventID) { _, eventID in
// VoiceOver focus on any transcript row means the user is
// reading history. Only Latest resumes automatic following.
if eventID != nil { followingLatest = false }
}
.onPreferenceChange(TranscriptViewportHeightKey.self) { height in
viewportHeight = height
updateFollowingEligibility(bottom: latestBottom, viewport: height)
}
.onPreferenceChange(TranscriptLatestBottomKey.self) { bottom in
latestBottom = bottom
updateFollowingEligibility(bottom: bottom, viewport: viewportHeight)
}
if !followingLatest && !events.isEmpty {
HStack {
Spacer()
@@ -77,6 +119,7 @@ struct TranscriptListView: View {
// Explicit Latest restores full eligibility,
// clearing any stale selection suspension.
selectionActive = false
focusedEventID = nil
followingLatest = true
withAnimation(reduceMotion ? nil : Nearlight.standard) {
proxy.scrollTo("latest", anchor: .bottom)
@@ -104,6 +147,17 @@ struct TranscriptListView: View {
}
}
private func updateFollowingEligibility(bottom: CGFloat, viewport: CGFloat) {
guard bottom > 0,
viewport > 0,
!selectionActive,
focusedEventID == nil else { return }
let shouldFollow = bottom <= viewport + 48
if followingLatest != shouldFollow {
followingLatest = shouldFollow
}
}
private func transcriptRow(_ event: TranscriptEvent) -> some View {
let isUser = event.role == .user
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
@@ -141,6 +195,7 @@ struct TranscriptListView: View {
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
}
.accessibilityElement(children: .combine)
.accessibilityFocused($focusedEventID, equals: event.id)
.id(event.id)
}
@@ -163,3 +218,19 @@ struct TranscriptListView: View {
.monospacedDigit()
}
}
private struct TranscriptViewportHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private struct TranscriptLatestBottomKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
+12 -21
View File
@@ -12,12 +12,9 @@ struct SettingsView: View {
@State private var urlText: String = ""
@State private var urlError: String?
@State private var showVoicePicker = false
@State private var urlJustSaved = false
@State private var savedURLConfirmation: String?
@State private var showModelPicker = false
@FocusState private var urlFocused: Bool
@State private var applyingTrim = false
/// Guards the success-trim against the edit observer (N3).
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
self.session = session
@@ -73,17 +70,15 @@ struct SettingsView: View {
.accessibilityLabel("Server URL")
.focused($urlFocused)
.onAppear { urlText = settings.serverURL }
.onChange(of: urlText) { oldValue, newValue in
// A programmatic normalization on successful Save must
// not erase its own confirmation; only user edits do.
guard applyingTrim == false else { return }
if newValue != oldValue {
urlJustSaved = false
urlError = nil
}
.onChange(of: urlText) { _, newValue in
// Keep confirmation across the asynchronous onChange
// caused by successful whitespace normalization.
guard newValue != savedURLConfirmation else { return }
savedURLConfirmation = nil
urlError = nil
}
.overlay(alignment: .trailing) {
if urlJustSaved {
if savedURLConfirmation != nil {
Image(systemName: "checkmark.circle")
.foregroundStyle(Nearlight.quiet)
.padding(.trailing, 8)
@@ -95,7 +90,7 @@ struct SettingsView: View {
.font(.footnote)
.foregroundStyle(Nearlight.alert)
.accessibilityIdentifier("settings.server.error")
} else if urlJustSaved {
} else if savedURLConfirmation != nil {
Text("Server address saved.")
.font(.footnote)
.foregroundStyle(Nearlight.quiet)
@@ -135,7 +130,7 @@ struct SettingsView: View {
/// left untouched so the active session keeps using its original URL.
/// Guarded: only applies when the session is idle (no active call).
private func applyServerURL() {
urlJustSaved = false
savedURLConfirmation = nil
guard canApplyServerURL else {
urlError = "End the conversation before changing servers."
return
@@ -148,13 +143,9 @@ struct SettingsView: View {
switch ServerConfig.parse(trimmed) {
case .success:
settings.serverURL = trimmed
if trimmed != urlText {
applyingTrim = true
urlText = trimmed
applyingTrim = false
}
urlText = trimmed
urlError = nil
urlJustSaved = true
savedURLConfirmation = trimmed
urlFocused = false
case .failure(let error):
urlError = error.localizedDescription