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:
1 parent
fa21c81787
commit
f043d6b078
5 files changed
+72
-30
No files matched your search
@@ -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:
|
||||
// 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 session.isMuted { return "Microphone off" }
|
||||
switch conversation.phase {
|
||||
case .listening: return "Listening"
|
||||
case .thinking: return "Thinking…"
|
||||
case .speaking: return "Hope is speaking"
|
||||
default: return "Listening"
|
||||
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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user