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

@@ -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) }
}