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
This commit is contained in:
snowspeeder committed 2026-09-12 08:20:43 -04:00
1 parent fa21c81787
commit f043d6b078
5 files changed
+72 -30

No files matched your search

+1 -9
View File
@@ -12,15 +12,7 @@ struct VoiceApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ConversationView(settings: settings) 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)
})
}
@@ -120,12 +120,15 @@ private func lightwellPhase(session: VoiceSession, conversation: ConversationSta
case .tearingDown: return .tearingDown case .tearingDown: return .tearingDown
case .failed: return .failed case .failed: return .failed
case .ready: case .ready:
if textMode { return .readyNoInput } // Only substitute idle input geometry when otherwise LISTENING.
if session.isMuted { return .readyNoInput } // Assistant output (speaking/thinking) must stay represented in
// Type and muted modes per spec.
switch conversation.phase { switch conversation.phase {
case .speaking: return .speaking(level: conversation.agentLevel) case .speaking: return .speaking(level: conversation.agentLevel)
case .thinking: return .thinking 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 { 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 { private var failureTitle: String {
@@ -293,13 +299,12 @@ private struct ConversationContent: View {
return micError return micError
} }
let raw = failureMessage ?? "" 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." return "Check that Hope is running on your server."
} }
if raw.lowercased().contains("timed out") || raw.lowercased().contains("connect") { return raw.isEmpty ? "The connection failed. Try again." : raw
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
} }
// MARK: Dock // MARK: Dock
@@ -316,6 +321,12 @@ private struct ConversationContent: View {
modeSelector modeSelector
if session.isReady { if session.isReady {
if settings.textMode { typeControls } else { voiceControls } 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 { } else {
startControls startControls
} }
@@ -534,13 +545,14 @@ private struct ConversationContent: View {
case .tearingDown: return "Ending conversation…" case .tearingDown: return "Ending conversation…"
case .failed: return "Connection interrupted" case .failed: return "Connection interrupted"
case .ready: case .ready:
// Assistant output outranks input-mode substitution.
switch conversation.phase {
case .speaking: return "Hope is speaking"
case .thinking: return "Thinking…"
default:
if settings.textMode { return "Ready for your message" } if settings.textMode { return "Ready for your message" }
if session.isMuted { return "Microphone off" } if session.isMuted { return "Microphone off" }
switch conversation.phase { return "Listening"
case .listening: return "Listening"
case .thinking: return "Thinking…"
case .speaking: return "Hope is speaking"
default: return "Listening"
} }
} }
} }
@@ -45,9 +45,10 @@ struct TranscriptListView: View {
.padding(.horizontal, Nearlight.s20) .padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s16) .padding(.top, Nearlight.s16)
.padding(.bottom, Nearlight.s20) .padding(.bottom, Nearlight.s20)
.frame(minHeight: 200, alignment: .topLeading)
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.bottom) .defaultScrollAnchor(.top)
.onChange(of: events.last?.text) { _, _ in .onChange(of: events.last?.text) { _, _ in
if followingLatest { proxy.scrollTo("latest", anchor: .bottom) } if followingLatest { proxy.scrollTo("latest", anchor: .bottom) }
} }
+14 -2
View File
@@ -14,6 +14,7 @@ struct SettingsView: View {
@State private var showVoicePicker = false @State private var showVoicePicker = false
@State private var urlJustSaved = false @State private var urlJustSaved = false
@State private var showModelPicker = false @State private var showModelPicker = false
@FocusState private var urlFocused: Bool
init(settings: SettingsStore, session: VoiceSession) { init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings self.settings = settings
@@ -60,12 +61,21 @@ struct SettingsView: View {
} }
} header: { Text("Intelligence") } } header: { Text("Intelligence") }
Section { Section {
Text("Server URL")
.font(.footnote.weight(.semibold))
.foregroundStyle(Nearlight.quiet)
TextField(SettingsStore.defaultServerURL, text: $urlText) TextField(SettingsStore.defaultServerURL, text: $urlText)
.keyboardType(.URL) .keyboardType(.URL)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.accessibilityLabel("Server URL") .accessibilityLabel("Server URL")
.focused($urlFocused)
.onAppear { urlText = settings.serverURL } .onAppear { urlText = settings.serverURL }
.onChange(of: urlText) { _, _ in
// Any edit clears the saved confirmation and error.
urlJustSaved = false
urlError = nil
}
.overlay(alignment: .trailing) { .overlay(alignment: .trailing) {
if urlJustSaved { if urlJustSaved {
Image(systemName: "checkmark.circle") Image(systemName: "checkmark.circle")
@@ -135,8 +145,10 @@ struct SettingsView: View {
urlText = trimmed urlText = trimmed
urlError = nil urlError = nil
urlJustSaved = true urlJustSaved = true
urlFocused = false
case .failure(let error): case .failure(let error):
urlError = error.localizedDescription urlError = error.localizedDescription
urlFocused = true
} }
} }
} }
@@ -160,7 +172,7 @@ struct VoicePickerView: View {
if settings.voiceID == voice.id { if settings.voiceID == voice.id {
Spacer() Spacer()
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundStyle(HopeStyle.tint) .foregroundStyle(Nearlight.rosewood)
} }
} }
} }
@@ -205,7 +217,7 @@ struct ModelPickerView: View {
if activeModelID == model.id { if activeModelID == model.id {
Spacer() Spacer()
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundStyle(HopeStyle.tint) .foregroundStyle(Nearlight.rosewood)
} }
} }
} }
+29 -4
View File
@@ -23,7 +23,7 @@ struct TaskPanelView: View {
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
ForEach(taskStore.sortedTasks) { task in ForEach(taskStore.sortedTasks) { task in
NavigationLink { NavigationLink {
TaskDetailView(task: task) TaskDetailView(taskStore: taskStore, taskID: task.id)
} label: { } label: {
TaskRowView(task: task) TaskRowView(task: task)
} }
@@ -130,7 +130,35 @@ private struct TaskRowView: View {
} }
/// Native push detail for one task: title, status, steps, result/error. /// 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 { 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 let task: TaskItem
@Environment(\.dynamicTypeSize) private var typeSize @Environment(\.dynamicTypeSize) private var typeSize
@@ -192,9 +220,6 @@ struct TaskDetailView: View {
.padding(.top, Nearlight.s24) .padding(.top, Nearlight.s24)
.padding(.bottom, Nearlight.s24) .padding(.bottom, Nearlight.s24)
} }
.background(Nearlight.ground)
.navigationTitle("Task")
.navigationBarTitleDisplayMode(.inline)
} }
private var statusTitle: String { private var statusTitle: String {