From f043d6b07814ff52e5d17c6e7eec9bc48a4a4855 Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 12 Sep 2026 08:20:43 -0400 Subject: [PATCH] Fix major Nearlight deviations from design review - 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 --- Sources/App/VoiceApp.swift | 10 +---- .../Conversation/ConversationView.swift | 40 ++++++++++++------- .../Conversation/TranscriptListView.swift | 3 +- Sources/Features/Settings/SettingsView.swift | 16 +++++++- Sources/Features/Tasks/TaskPanelView.swift | 33 +++++++++++++-- 5 files changed, 72 insertions(+), 30 deletions(-) diff --git a/Sources/App/VoiceApp.swift b/Sources/App/VoiceApp.swift index bd5eb92..fa2e68f 100644 --- a/Sources/App/VoiceApp.swift +++ b/Sources/App/VoiceApp.swift @@ -12,15 +12,7 @@ struct VoiceApp: App { var body: some Scene { WindowGroup { ConversationView(settings: settings) - .tint(HopeStyle.tint) + .tint(Nearlight.rosewood) } } } - -enum HopeStyle { - static let tint = Color(uiColor: UIColor { traits in - traits.userInterfaceStyle == .dark - ? UIColor(red: 0.43, green: 0.68, blue: 1, alpha: 1) - : UIColor(red: 0.12, green: 0.32, blue: 0.68, alpha: 1) - }) -} diff --git a/Sources/Features/Conversation/ConversationView.swift b/Sources/Features/Conversation/ConversationView.swift index dd3d095..d028d4d 100644 --- a/Sources/Features/Conversation/ConversationView.swift +++ b/Sources/Features/Conversation/ConversationView.swift @@ -120,12 +120,15 @@ private func lightwellPhase(session: VoiceSession, conversation: ConversationSta case .tearingDown: return .tearingDown case .failed: return .failed case .ready: - if textMode { return .readyNoInput } - if session.isMuted { return .readyNoInput } + // Only substitute idle input geometry when otherwise LISTENING. + // Assistant output (speaking/thinking) must stay represented in + // Type and muted modes per spec. switch conversation.phase { case .speaking: return .speaking(level: conversation.agentLevel) case .thinking: return .thinking - default: return .listening(level: conversation.micLevel) + default: + if textMode || session.isMuted { return .readyNoInput } + return .listening(level: conversation.micLevel) } } } @@ -280,7 +283,10 @@ private struct ConversationContent: View { } private var micDenied: Bool { - (session.micError?.contains("Microphone") ?? false) && session.state.isFailed == false && session.state == .ready + // 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) } private var failureTitle: String { @@ -293,13 +299,12 @@ private struct ConversationContent: View { return micError } let raw = failureMessage ?? "" - if raw.lowercased().contains("agent") { + // Only the agent-timeout path is a known classified cause; every + // other failure keeps its actual readable error per spec. + if raw.contains("Agent never joined") { return "Check that Hope is running on your server." } - if raw.lowercased().contains("timed out") || raw.lowercased().contains("connect") { - return "Check your Wi-Fi or VPN and make sure your server is running." - } - return raw.isEmpty ? "Check your Wi-Fi or VPN and make sure your server is running." : raw + return raw.isEmpty ? "The connection failed. Try again." : raw } // MARK: Dock @@ -316,6 +321,12 @@ private struct ConversationContent: View { modeSelector if session.isReady { if settings.textMode { typeControls } else { voiceControls } + } else if isBusy && !session.state.isFailed { + // Connecting/waiting: keep the draft readable and expose the + // existing cancellation path per spec. + if settings.textMode { typeControls } + else { HStack(spacing: Nearlight.s12) { muteButton; micMeter } } + endButton } else { startControls } @@ -534,13 +545,14 @@ private struct ConversationContent: View { case .tearingDown: return "Ending conversation…" case .failed: return "Connection interrupted" case .ready: - if settings.textMode { return "Ready for your message" } - if session.isMuted { return "Microphone off" } + // Assistant output outranks input-mode substitution. switch conversation.phase { - case .listening: return "Listening" - case .thinking: return "Thinking…" case .speaking: return "Hope is speaking" - default: return "Listening" + case .thinking: return "Thinking…" + default: + if settings.textMode { return "Ready for your message" } + if session.isMuted { return "Microphone off" } + return "Listening" } } } diff --git a/Sources/Features/Conversation/TranscriptListView.swift b/Sources/Features/Conversation/TranscriptListView.swift index ce279c4..aa8c03c 100644 --- a/Sources/Features/Conversation/TranscriptListView.swift +++ b/Sources/Features/Conversation/TranscriptListView.swift @@ -45,9 +45,10 @@ struct TranscriptListView: View { .padding(.horizontal, Nearlight.s20) .padding(.top, Nearlight.s16) .padding(.bottom, Nearlight.s20) + .frame(minHeight: 200, alignment: .topLeading) } .scrollDismissesKeyboard(.interactively) - .defaultScrollAnchor(.bottom) + .defaultScrollAnchor(.top) .onChange(of: events.last?.text) { _, _ in if followingLatest { proxy.scrollTo("latest", anchor: .bottom) } } diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index 88ae0ec..8dea03b 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -14,6 +14,7 @@ struct SettingsView: View { @State private var showVoicePicker = false @State private var urlJustSaved = false @State private var showModelPicker = false + @FocusState private var urlFocused: Bool init(settings: SettingsStore, session: VoiceSession) { self.settings = settings @@ -60,12 +61,21 @@ struct SettingsView: View { } } header: { Text("Intelligence") } Section { + Text("Server URL") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Nearlight.quiet) TextField(SettingsStore.defaultServerURL, text: $urlText) .keyboardType(.URL) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityLabel("Server URL") + .focused($urlFocused) .onAppear { urlText = settings.serverURL } + .onChange(of: urlText) { _, _ in + // Any edit clears the saved confirmation and error. + urlJustSaved = false + urlError = nil + } .overlay(alignment: .trailing) { if urlJustSaved { Image(systemName: "checkmark.circle") @@ -135,8 +145,10 @@ struct SettingsView: View { urlText = trimmed urlError = nil urlJustSaved = true + urlFocused = false case .failure(let error): urlError = error.localizedDescription + urlFocused = true } } } @@ -160,7 +172,7 @@ struct VoicePickerView: View { if settings.voiceID == voice.id { Spacer() Image(systemName: "checkmark") - .foregroundStyle(HopeStyle.tint) + .foregroundStyle(Nearlight.rosewood) } } } @@ -205,7 +217,7 @@ struct ModelPickerView: View { if activeModelID == model.id { Spacer() Image(systemName: "checkmark") - .foregroundStyle(HopeStyle.tint) + .foregroundStyle(Nearlight.rosewood) } } } diff --git a/Sources/Features/Tasks/TaskPanelView.swift b/Sources/Features/Tasks/TaskPanelView.swift index e53c112..8766d1c 100644 --- a/Sources/Features/Tasks/TaskPanelView.swift +++ b/Sources/Features/Tasks/TaskPanelView.swift @@ -23,7 +23,7 @@ struct TaskPanelView: View { LazyVStack(alignment: .leading, spacing: 0) { ForEach(taskStore.sortedTasks) { task in NavigationLink { - TaskDetailView(task: task) + TaskDetailView(taskStore: taskStore, taskID: task.id) } label: { TaskRowView(task: task) } @@ -130,7 +130,35 @@ private struct TaskRowView: View { } /// Native push detail for one task: title, status, steps, result/error. +/// Looks the task up live in the store so removal shows the missing state. struct TaskDetailView: View { + @ObservedObject var taskStore: TaskStore + let taskID: String + @Environment(\.dynamicTypeSize) private var typeSize + + private var task: TaskItem? { + taskStore.tasks[taskID] + } + + var body: some View { + Group { + if let task { + TaskDetailContent(task: task) + } else { + Text("This task is no longer available.") + .font(.body) + .foregroundStyle(Nearlight.quiet) + .padding(Nearlight.s20) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .background(Nearlight.ground) + .navigationTitle("Task") + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct TaskDetailContent: View { let task: TaskItem @Environment(\.dynamicTypeSize) private var typeSize @@ -192,9 +220,6 @@ struct TaskDetailView: View { .padding(.top, Nearlight.s24) .padding(.bottom, Nearlight.s24) } - .background(Nearlight.ground) - .navigationTitle("Task") - .navigationBarTitleDisplayMode(.inline) } private var statusTitle: String {