import SwiftUI import Combine struct ConversationView: View { @ObservedObject var settings: SettingsStore @State private var sessionID = UUID() @State private var session: VoiceSession @State private var composerText = "" @State private var connectError: String? @State private var tick = 0 init(settings: SettingsStore) { self.settings = settings _session = State(initialValue: Self.makeSession(settings: settings)) } private static func makeSession(settings: SettingsStore) -> VoiceSession { let config = ServerConfig.parse(settings.trimmedServerURL) if case .success(let value) = config { return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings) } let fallback = ServerConfig.parse("https://192.168.86.2:9178") if case .success(let value) = fallback { return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings) } fatalError("Unreachable: default server URL is valid") } var body: some View { NavigationStack { VStack(spacing: 0) { statusHeader TranscriptListView( events: session.transcriptHandler.events, history: session.transcriptHandler.history, onClear: { session.transcriptHandler.clearHistory() } ) composer } .navigationTitle("Hope Voice") .toolbar { toolbarContent } .id(sessionID) .onAppear { if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) { connectError = error.localizedDescription } } } .onReceive(session.objectWillChange.receive(on: RunLoop.main)) { _ in tick &+= 1 } .onChange(of: settings.serverURL) { _, _ in guard session.state == .idle else { return } let oldSession = session Task { @MainActor [settings] in await oldSession.disconnect() self.sessionID = UUID() self.session = Self.makeSession(settings: settings) } } } private var statusHeader: some View { VStack(spacing: 12) { OrbView(state: orbState) Text(statusText) .font(.headline) if session.state == .ready, session.stateModel.isThinkingChipVisible { Label("Thinking…", systemImage: "ellipsis") .font(.caption) .padding(.horizontal, 10) .padding(.vertical, 4) .background(.orange.opacity(0.2), in: Capsule()) } if session.state == .ready, !settings.textMode { micMeter } if session.state == .ready, settings.textMode { Text("Barge in anytime — type to reply") .font(.caption) .foregroundStyle(.secondary) } if let agent = session.agent { Text("Agent: \(agent.name ?? agent.identity)") .font(.caption) .foregroundStyle(.secondary) } if session.state == .ready { Text("\(Voices.display(for: settings.voiceID) ?? settings.voiceID) · \(session.selectedModel ?? settings.modelID ?? "—")") .font(.caption) .foregroundStyle(.secondary) } if session.state == .ready { HStack(spacing: 16) { Toggle(isOn: Binding( get: { !session.isMuted }, set: { newValue in Task { await session.setMuted(!newValue) } } )) { Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic") } .toggleStyle(.button) Toggle("Text mode", isOn: textModeBinding) .toggleStyle(.button) } } if session.state == .ready, session.isReconnecting { Label("Connection lost — reconnecting…", systemImage: "arrow.triangle.2.circlepath") .font(.footnote) .foregroundStyle(.orange) .frame(maxWidth: .infinity, alignment: .leading) } if let micError = session.micError { Label(micError, systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(.orange) .frame(maxWidth: .infinity, alignment: .leading) } if let connectError { Label(connectError, systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(.red) } } .padding() } private var micMeter: some View { GeometryReader { geo in ZStack(alignment: .leading) { Capsule() .fill(Color(.systemFill)) Capsule() .fill(Color.green.gradient) .frame(width: min(geo.size.width, geo.size.width * session.stateModel.micLevel * 2.5)) } } .frame(height: 4) } private var textModeBinding: Binding { Binding( get: { settings.textMode }, set: { newValue in settings.textMode = newValue Task { await applyTextMode(newValue) } } ) } private func applyTextMode(_ enabled: Bool) async { guard session.isReady else { return } if enabled { await session.setMuted(true) await session.setMode("text") } else { await session.setMode("voice") await session.enableMicrophone() } } private var composer: some View { HStack(spacing: 8) { switch session.state { case .idle, .failed: Button(action: startSession) { Label("Start", systemImage: "phone.fill") } .buttonStyle(.borderedProminent) case .connecting, .waitingAgent: ProgressView() case .ready: TextField(settings.textMode ? "Type a message…" : "Voice mode active", text: $composerText, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...4) .disabled(!settings.textMode) Button(action: sendComposer) { Image(systemName: "arrow.up.circle.fill") .font(.title2) } .accessibilityLabel("Send message") .disabled(!settings.textMode || composerText.isEmpty) Button(role: .destructive, action: endSession) { Image(systemName: "phone.down.fill") } .accessibilityLabel("End conversation") } } .padding() } @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { NavigationLink { TaskPanelView(taskStore: session.taskStore) } label: { Image(systemName: "tray.full") .overlay(alignment: .topTrailing) { if session.taskStore.activeCount > 0 { Text("\(session.taskStore.activeCount)") .font(.caption2.weight(.bold)) .foregroundStyle(.white) .padding(3) .background(Color.red, in: Circle()) .offset(x: 6, y: -6) } } } .accessibilityLabel("Background tasks") } ToolbarItem(placement: .topBarTrailing) { NavigationLink { MediaPanelView(playerController: session.mediaController, settings: settings) } label: { Image(systemName: "music.note") } .accessibilityLabel("Media player") } ToolbarItem(placement: .topBarTrailing) { NavigationLink { SettingsView(settings: settings, session: session) } label: { Image(systemName: "gearshape") } .accessibilityLabel("Settings") } } private var orbState: OrbView.OrbState { switch session.state { case .idle: return .idle case .connecting, .waitingAgent: return .connecting case .ready: guard !settings.textMode else { return .idle } switch session.stateModel.phase { case .idle: return .idle case .connecting: return .connecting case .listening: return .listening case .thinking: return .thinking case .speaking: return .speaking } case .failed: return .failed } } private var statusText: String { switch session.state { case .idle: return "Ready to connect" case .connecting: return "Connecting…" case .waitingAgent: return "Waiting for agent…" case .ready: if settings.textMode { return "Text mode" } switch session.stateModel.phase { case .listening: return "Listening" case .thinking: return "Thinking it through" case .speaking: return "Hope is speaking" default: return "Ready when you are" } case .failed(let message): return "Failed: \(message)" } } private func startSession() { connectError = nil session.transcriptHandler.clear() Task { await session.connect() } } private func endSession() { Task { await session.disconnect() } } private func sendComposer() { let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } composerText = "" Task { do { try await session.sendText(text) } catch { connectError = error.localizedDescription } } } } #Preview { ConversationView(settings: SettingsStore()) }