Archived
- 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
598 lines
25 KiB
Swift
598 lines
25 KiB
Swift
import SwiftUI
|
||
|
||
/// Owns one session across all tabs. Changing tabs never ends a conversation.
|
||
struct ConversationView: View {
|
||
@ObservedObject var settings: SettingsStore
|
||
@State private var session: VoiceSession
|
||
@State private var activeServerURL: String
|
||
@State private var pendingServerURL: String?
|
||
@State private var isReplacingSession = false
|
||
@State private var sessionID = UUID()
|
||
@State private var selectedTab = 0
|
||
|
||
init(settings: SettingsStore) {
|
||
self.settings = settings
|
||
let url = settings.trimmedServerURL
|
||
_activeServerURL = State(initialValue: url)
|
||
_session = State(initialValue: Self.makeSession(url, settings: settings))
|
||
}
|
||
|
||
private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession {
|
||
guard case .success(let config) = ServerConfig.parse(url) else {
|
||
preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.")
|
||
}
|
||
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
|
||
#if DEBUG
|
||
if ProcessInfo.processInfo.arguments.contains("--ui-fixtures") {
|
||
session.transcriptHandler.receiveDataTranscript(role: .user, text: "Help me plan a quiet weekend.")
|
||
session.transcriptHandler.receiveDataTranscript(role: .agent, text: "Let’s leave some room to breathe. A walk, a good book, and time for your favorite music would be a lovely start.")
|
||
session.taskStore.injectSampleEvents()
|
||
}
|
||
#endif
|
||
return session
|
||
}
|
||
|
||
var body: some View {
|
||
TabView(selection: $selectedTab) {
|
||
NavigationStack {
|
||
ConversationContent(settings: settings, session: session)
|
||
.toolbar { settingsLink }
|
||
}
|
||
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
|
||
.tag(0)
|
||
|
||
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
|
||
.tag(1)
|
||
}
|
||
.id(sessionID)
|
||
.tint(Nearlight.rosewood)
|
||
.onChange(of: settings.textMode) { _, enabled in
|
||
Task { await session.applyTextMode(enabled) }
|
||
}
|
||
.onChange(of: settings.serverURL) { _, value in replaceServer(value) }
|
||
}
|
||
|
||
@ToolbarContentBuilder private var settingsLink: some ToolbarContent {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
NavigationLink {
|
||
SettingsView(settings: settings, session: session)
|
||
} label: {
|
||
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
|
||
}
|
||
.accessibilityLabel("Settings")
|
||
}
|
||
}
|
||
|
||
private func replaceServer(_ value: String) {
|
||
let url = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard case .success = ServerConfig.parse(url) else { return }
|
||
pendingServerURL = url
|
||
guard !isReplacingSession else { return }
|
||
isReplacingSession = true
|
||
Task { @MainActor in
|
||
// Coalesce rapid Apply actions; construct from the captured URL.
|
||
while let next = pendingServerURL {
|
||
pendingServerURL = nil
|
||
guard next != activeServerURL else { continue }
|
||
await session.disconnect()
|
||
activeServerURL = next
|
||
session = Self.makeSession(next, settings: settings)
|
||
sessionID = UUID()
|
||
}
|
||
isReplacingSession = false
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct ActivityTab: View {
|
||
@ObservedObject var taskStore: TaskStore
|
||
@ObservedObject var settings: SettingsStore
|
||
@ObservedObject var session: VoiceSession
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
TaskPanelView(taskStore: taskStore)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
NavigationLink {
|
||
SettingsView(settings: settings, session: session)
|
||
} label: {
|
||
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
|
||
}
|
||
.accessibilityLabel("Settings")
|
||
}
|
||
}
|
||
}
|
||
.tabItem { Label("Activity", systemImage: "checklist") }
|
||
.badge(taskStore.activeCount)
|
||
}
|
||
}
|
||
|
||
/// Maps session + conversation models onto Lightwell presentation phases.
|
||
@MainActor
|
||
private func lightwellPhase(session: VoiceSession, conversation: ConversationStateModel,
|
||
textMode: Bool) -> LightwellView.Phase {
|
||
if session.state.isFailed { return .failed }
|
||
switch session.state {
|
||
case .idle: return .idle
|
||
case .connecting: return .connecting
|
||
case .waitingAgent: return .waiting
|
||
case .tearingDown: return .tearingDown
|
||
case .failed: return .failed
|
||
case .ready:
|
||
// 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:
|
||
if textMode || session.isMuted { return .readyNoInput }
|
||
return .listening(level: conversation.micLevel)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct ConversationContent: View {
|
||
@ObservedObject var settings: SettingsStore
|
||
@ObservedObject var session: VoiceSession
|
||
@ObservedObject private var transcripts: TranscriptHandler
|
||
@ObservedObject private var conversation: ConversationStateModel
|
||
@State private var composerText = ""
|
||
@State private var sendError: String?
|
||
@State private var isSending = false
|
||
@FocusState private var composerFocused: Bool
|
||
@Environment(\.dynamicTypeSize) private var typeSize
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
|
||
init(settings: SettingsStore, session: VoiceSession) {
|
||
self.settings = settings
|
||
self.session = session
|
||
transcripts = session.transcriptHandler
|
||
conversation = session.stateModel
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
if transcripts.events.isEmpty && !session.isReady {
|
||
welcome
|
||
if failureNotice != nil { failureBanner }
|
||
} else {
|
||
statusStrip
|
||
if session.isReconnecting { reconnectBanner }
|
||
if failureNotice != nil { failureBanner }
|
||
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
|
||
}
|
||
}
|
||
.background(Nearlight.ground)
|
||
.navigationTitle("Hope")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.safeAreaInset(edge: .bottom, spacing: 0) { dock }
|
||
.onChange(of: session.state) { _, state in
|
||
if state == .ready && settings.textMode { composerFocused = true }
|
||
}
|
||
.sensoryFeedback(.success, trigger: session.state == .ready ? true : false) { _, _ in true }
|
||
.sensoryFeedback(.error, trigger: session.state.isFailed) { _, _ in true }
|
||
}
|
||
|
||
// MARK: Welcome (pre-connection, no history)
|
||
|
||
private var welcome: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: Nearlight.s24) {
|
||
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
|
||
.padding(.top, Nearlight.s32)
|
||
VStack(alignment: .leading, spacing: Nearlight.s12) {
|
||
Text("A moment with Hope.")
|
||
.font(NearlightFont.welcome(typeSize))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Text("Speak or type to your own assistant.")
|
||
.font(NearlightFont.support(typeSize))
|
||
.foregroundStyle(Nearlight.quiet)
|
||
}
|
||
statusLine
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, Nearlight.s20)
|
||
.padding(.bottom, Nearlight.s24)
|
||
}
|
||
}
|
||
|
||
/// Status text 24 below the supporting copy on welcome.
|
||
private var statusLine: some View {
|
||
Text(statusTitle)
|
||
.font(NearlightFont.support(typeSize))
|
||
.foregroundStyle(Nearlight.quiet)
|
||
.padding(.top, Nearlight.s24)
|
||
.accessibilityIdentifier("talk.status")
|
||
}
|
||
|
||
// MARK: Status strip (active conversation)
|
||
|
||
private var statusStrip: some View {
|
||
HStack(spacing: Nearlight.s12) {
|
||
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(statusTitle)
|
||
.font(NearlightFont.control(typeSize))
|
||
.foregroundStyle(Nearlight.ink)
|
||
.accessibilityIdentifier("talk.status")
|
||
if showStatusDetail {
|
||
Text(statusDetail)
|
||
.font(NearlightFont.support(typeSize))
|
||
.foregroundStyle(Nearlight.quiet)
|
||
}
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.horizontal, Nearlight.s20)
|
||
.padding(.vertical, Nearlight.s12)
|
||
.frame(minHeight: 60, alignment: .leading)
|
||
.background(Nearlight.ground)
|
||
}
|
||
|
||
/// Subtitle omitted for ordinary listening/thinking/speaking per spec.
|
||
private var showStatusDetail: Bool {
|
||
guard session.isReady else { return true }
|
||
if settings.textMode || session.isMuted { return true }
|
||
return false
|
||
}
|
||
|
||
private var reconnectBanner: some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Label("Reconnecting…", systemImage: "wifi.exclamationmark")
|
||
.font(NearlightFont.control(typeSize))
|
||
Text("Your conversation is still here.")
|
||
.font(NearlightFont.support(typeSize))
|
||
.foregroundStyle(Nearlight.quiet)
|
||
}
|
||
.padding(Nearlight.s12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Nearlight.surface)
|
||
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||
.padding(.horizontal, Nearlight.s20)
|
||
.accessibilityIdentifier("talk.connection.notice")
|
||
}
|
||
|
||
/// Classified failure notice with actionable recovery.
|
||
private var failureBanner: some View {
|
||
VStack(alignment: .leading, spacing: Nearlight.s8) {
|
||
Label(failureTitle, systemImage: "exclamationmark.circle")
|
||
.font(NearlightFont.control(typeSize))
|
||
.foregroundStyle(Nearlight.alert)
|
||
Text(failureBody)
|
||
.font(NearlightFont.support(typeSize))
|
||
.foregroundStyle(Nearlight.ink)
|
||
if micDenied {
|
||
Button("Open iPhone Settings") {
|
||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
.font(NearlightFont.support(typeSize))
|
||
.frame(minHeight: 44)
|
||
.accessibilityIdentifier("talk.connection.openSystemSettings")
|
||
}
|
||
}
|
||
.padding(Nearlight.s16)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Nearlight.errorWash)
|
||
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||
.padding(.horizontal, Nearlight.s20)
|
||
.accessibilityIdentifier("talk.connection.notice")
|
||
}
|
||
|
||
private var micDenied: Bool {
|
||
// 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 {
|
||
if session.micError != nil { return "Microphone unavailable" }
|
||
return session.everConnected ? "Connection interrupted" : "Couldn’t connect"
|
||
}
|
||
|
||
private var failureBody: String {
|
||
if let micError = session.micError {
|
||
return micError
|
||
}
|
||
let raw = failureMessage ?? ""
|
||
// 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 raw.isEmpty ? "The connection failed. Try again." : raw
|
||
}
|
||
|
||
// MARK: Dock
|
||
|
||
private var dock: some View {
|
||
VStack(spacing: Nearlight.s12) {
|
||
if let sendError {
|
||
Label(sendError, systemImage: "exclamationmark.circle")
|
||
.font(NearlightFont.support(typeSize))
|
||
.foregroundStyle(Nearlight.alert)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.accessibilityIdentifier("talk.composer.error")
|
||
}
|
||
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
|
||
}
|
||
}
|
||
.padding(.horizontal, Nearlight.s20)
|
||
.padding(.top, Nearlight.s12)
|
||
.padding(.bottom, Nearlight.s12)
|
||
.background(
|
||
// Dock: Surface, 16-point top corners, decorative top separator.
|
||
VStack(spacing: 0) {
|
||
Rectangle().fill(Nearlight.separator).frame(height: 1 / UIScreen.main.scale)
|
||
Nearlight.surface
|
||
}
|
||
.ignoresSafeArea(.container, edges: .bottom)
|
||
.clipShape(UnevenRoundedRectangle(topLeadingRadius: Nearlight.radiusDock, topTrailingRadius: Nearlight.radiusDock))
|
||
)
|
||
}
|
||
|
||
@ViewBuilder private var modeSelector: some View {
|
||
if typeSize.isAccessibilitySize {
|
||
// Two stacked 52-point selection buttons at accessibility sizes.
|
||
VStack(spacing: Nearlight.s8) {
|
||
modeButton("Voice", systemImage: "mic", selected: !settings.textMode, id: "talk.mode.voice") {
|
||
settings.textMode = false
|
||
}
|
||
modeButton("Type", systemImage: "keyboard", selected: settings.textMode, id: "talk.mode.type") {
|
||
settings.textMode = true
|
||
}
|
||
}
|
||
.disabled(isBusy)
|
||
} else {
|
||
Picker("Conversation mode", selection: $settings.textMode) {
|
||
Label("Voice", systemImage: "mic").tag(false)
|
||
Label("Type", systemImage: "keyboard").tag(true)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.disabled(isBusy)
|
||
}
|
||
}
|
||
|
||
private func modeButton(_ title: String, systemImage: String, selected: Bool,
|
||
id: String, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
HStack(spacing: Nearlight.s8) {
|
||
Image(systemName: systemImage)
|
||
Text(title)
|
||
if selected { Spacer(); Image(systemName: "checkmark") }
|
||
}
|
||
.font(NearlightFont.control(typeSize))
|
||
.foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink)
|
||
.frame(maxWidth: .infinity, minHeight: 52)
|
||
.background(selected ? Nearlight.selectedWash : Nearlight.surface,
|
||
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||
.strokeBorder(selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet, lineWidth: 1)
|
||
)
|
||
}
|
||
.accessibilityIdentifier(id)
|
||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||
}
|
||
|
||
private var startControls: some View {
|
||
VStack(spacing: Nearlight.s8) {
|
||
Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.")
|
||
.font(NearlightFont.support(typeSize))
|
||
.foregroundStyle(Nearlight.quiet)
|
||
Button {
|
||
sendError = nil
|
||
Task { await session.connect() }
|
||
} label: {
|
||
HStack(spacing: Nearlight.s8) {
|
||
if isBusy {
|
||
ProgressView().tint(Nearlight.onRosewood)
|
||
} else {
|
||
Image(systemName: settings.textMode ? "keyboard" : "mic")
|
||
}
|
||
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : "Start chatting")
|
||
.font(NearlightFont.control(typeSize))
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 52)
|
||
.background(isBusy ? Nearlight.rosewood.opacity(0.6) : Nearlight.rosewood,
|
||
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||
.foregroundStyle(Nearlight.onRosewood)
|
||
}
|
||
.disabled(isBusy)
|
||
.accessibilityIdentifier("start.chatting")
|
||
}
|
||
}
|
||
|
||
private var voiceControls: some View {
|
||
Group {
|
||
if typeSize.isAccessibilitySize {
|
||
VStack(spacing: Nearlight.s12) {
|
||
HStack(spacing: Nearlight.s12) {
|
||
muteButton
|
||
micMeter
|
||
}
|
||
endButton
|
||
}
|
||
} else {
|
||
HStack(spacing: Nearlight.s12) {
|
||
muteButton
|
||
micMeter
|
||
endButton
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var muteButton: some View {
|
||
Button {
|
||
Task { await session.setMuted(!session.isMuted) }
|
||
} label: {
|
||
Image(systemName: session.isMuted ? "mic.slash" : "mic")
|
||
.font(.system(size: 20, weight: .medium))
|
||
.frame(width: 52, height: 52)
|
||
.background(session.isMuted ? Nearlight.selectedWash : Nearlight.surface,
|
||
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||
.strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||
)
|
||
.foregroundStyle(session.isMuted ? Nearlight.rosewood : Nearlight.ink)
|
||
}
|
||
.accessibilityLabel(session.isMuted ? "Unmute microphone" : "Mute microphone")
|
||
.accessibilityIdentifier("talk.microphone.toggle")
|
||
.sensoryFeedback(.selection, trigger: session.isMuted)
|
||
}
|
||
|
||
/// Five-bar capsule meter, 64×24, leading aligned.
|
||
private var micMeter: some View {
|
||
HStack(spacing: 6) {
|
||
let level = session.isMuted || settings.textMode ? 0.0 : conversation.micLevel
|
||
let filled = Int(ceil(level * 5))
|
||
ForEach(0..<5, id: \.self) { index in
|
||
Capsule()
|
||
.fill(index < filled ? Nearlight.rosewood : Color.clear)
|
||
.overlay(index < filled ? nil : Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
|
||
.frame(width: 6, height: [6.0, 12.0, 24.0, 12.0, 6.0][index])
|
||
}
|
||
}
|
||
.frame(width: 64, height: 24, alignment: .leading)
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel("Microphone level")
|
||
.accessibilityValue(session.isMuted || settings.textMode ? "Off" : "\(Int(round(conversation.micLevel * 100)))%")
|
||
}
|
||
|
||
private var typeControls: some View {
|
||
VStack(spacing: Nearlight.s12) {
|
||
HStack(alignment: .bottom, spacing: Nearlight.s8) {
|
||
TextField("Message Hope", text: $composerText, axis: .vertical)
|
||
.lineLimit(1...5)
|
||
.padding(Nearlight.s12)
|
||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||
.strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||
)
|
||
.focused($composerFocused)
|
||
.disabled(isSending)
|
||
.accessibilityLabel("Message Hope")
|
||
Button(action: sendComposer) {
|
||
if isSending {
|
||
ProgressView().frame(width: 52, height: 52)
|
||
} else {
|
||
Image(systemName: "arrow.up")
|
||
.font(.system(size: 20, weight: .medium))
|
||
.frame(width: 52, height: 52)
|
||
.background(Nearlight.rosewood, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||
.foregroundStyle(Nearlight.onRosewood)
|
||
}
|
||
}
|
||
.disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting)
|
||
.accessibilityLabel("Send message")
|
||
}
|
||
endButton
|
||
}
|
||
}
|
||
|
||
private var endButton: some View {
|
||
Button(role: .destructive) {
|
||
composerFocused = false
|
||
Task { await session.disconnect() }
|
||
} label: {
|
||
Label("End conversation", systemImage: "stop.fill")
|
||
.font(NearlightFont.control(typeSize))
|
||
.lineLimit(2...3)
|
||
.frame(maxWidth: .infinity, minHeight: 52)
|
||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||
.strokeBorder(Nearlight.alert, lineWidth: 1)
|
||
)
|
||
.foregroundStyle(Nearlight.alert)
|
||
}
|
||
.accessibilityIdentifier("end.conversation")
|
||
}
|
||
|
||
// MARK: Status copy
|
||
|
||
private var isBusy: Bool {
|
||
session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown
|
||
}
|
||
private var failureMessage: String? {
|
||
if case .failed(let message) = session.state { return message }
|
||
return nil
|
||
}
|
||
private var failureNotice: String? { failureMessage }
|
||
|
||
private var statusTitle: String {
|
||
switch session.state {
|
||
case .idle: return "Ready when you are"
|
||
case .connecting: return "Connecting to your server…"
|
||
case .waitingAgent: return "Waiting for Hope…"
|
||
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" }
|
||
return "Listening"
|
||
}
|
||
}
|
||
}
|
||
|
||
private var statusDetail: String {
|
||
if session.state == .waitingAgent { return "The server is connected. Hope has not joined yet." }
|
||
if session.state == .connecting { return "Reaching your server." }
|
||
if !session.isReady { return "" }
|
||
if settings.textMode { return "Microphone off · Type whenever you’re ready" }
|
||
if session.isMuted { return "You can still hear Hope." }
|
||
return ""
|
||
}
|
||
|
||
private func sendComposer() {
|
||
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty, !isSending else { return }
|
||
isSending = true
|
||
sendError = nil
|
||
Task {
|
||
do {
|
||
try await session.sendText(text)
|
||
composerText = ""
|
||
} catch {
|
||
sendError = "Your message wasn’t sent. Try again when connected."
|
||
}
|
||
isSending = false
|
||
}
|
||
}
|
||
}
|
||
|
||
extension VoiceSession {
|
||
/// Whether any connection reached ready during this session lifetime.
|
||
/// Used to title the failure notice per spec.
|
||
var everConnected: Bool {
|
||
// Sessions are recreated on replacement; reaching ready is recorded
|
||
// indirectly through transcript presence. Conservative: treat any
|
||
// prior transcript activity as evidence of a completed connection.
|
||
!transcriptHandler.events.isEmpty
|
||
}
|
||
}
|
||
|
||
#Preview { ConversationView(settings: SettingsStore()) }
|