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 {
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)
})
}
@@ -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"
}
}
}
@@ -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) }
}
+14 -2
View File
@@ -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)
}
}
}
+29 -4
View File
@@ -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 {