import SwiftUI import Combine struct ConversationView: View { @ObservedObject var settings: SettingsStore @State private var sessionID = UUID() @State private var session: VoiceSession /// The exact server URL the current session was constructed with. Used to /// avoid redundant session replacement when the applied URL hasn't actually /// changed (e.g. re-applying the same value from Settings). @State private var activeServerURL: String /// Identity guard: prevents a second `.onChange` from starting a concurrent /// session replacement while the first is still disconnecting the old one. @State private var isReplacingSession = false /// Latest accepted server URL that has not yet been applied to a session. /// Coalesced: rapid Applies during an active replacement retain the newest /// value rather than dropping it. A single retained worker loop drains /// arbitrary pending URLs until none remain (Iteration 08 Item 2). @State private var pendingServerURL: String? @State private var composerText = "" @State private var connectError: String? @State private var tick = 0 init(settings: SettingsStore) { self.settings = settings let initialSession = Self.makeSession(settings: settings) _session = State(initialValue: initialSession) _activeServerURL = State(initialValue: settings.trimmedServerURL) } /// Creates a session from the configured server URL. `SettingsStore.init` /// guarantees the stored value is always valid (legacy invalid values are /// sanitized to the documented default), so the failure branch is a safety /// net only. private static func makeSession(settings: SettingsStore) -> VoiceSession { let config = ServerConfig.parse(settings.trimmedServerURL) switch config { case .success(let value): return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings) case .failure: // Defensive: should be unreachable because SettingsStore sanitizes. let fallback = ServerConfig.parse(SettingsStore.defaultServerURL) if case .success(let value) = fallback { settings.serverURL = SettingsStore.defaultServerURL 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, onClear: { // Atomically clear the canonical visible collection, // dedupe state, and persistence so Clear is truthful. session.transcriptHandler.clearAll() } ) composer } .navigationTitle("Hope Voice") .toolbar { toolbarContent } .id(sessionID) .onAppear { if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) { connectError = error.localizedDescription } } .onChange(of: settings.textMode) { _, newValue in guard session.isReady else { return } Task { await session.applyTextMode(newValue) } } } .onReceive(session.objectWillChange.receive(on: RunLoop.main)) { _ in tick &+= 1 } .onChange(of: settings.serverURL) { _, newValue in // The URL field now uses explicit Apply semantics (SettingsView), // so this fires only when the user has applied a value that passed // validation. Coalesce rapid accepted values: every accepted Apply // writes `pendingServerURL` (latest wins). A single retained worker // loop drains arbitrary pending URLs until none remain, covering // A→B→C semantics where C arrives while B is disconnecting // (Iteration 08 Item 2). let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, case .success = ServerConfig.parse(trimmed) else { return } pendingServerURL = trimmed // Start the worker if not already active. The loop consumes // pending values until empty, so a single Task handles any number // of rapid Applies without nested tasks. guard !isReplacingSession else { return } isReplacingSession = true Task { @MainActor [settings] in while let url = self.pendingServerURL { self.pendingServerURL = nil guard url != self.activeServerURL else { continue } let oldSession = self.session await oldSession.disconnect() self.activeServerURL = url self.sessionID = UUID() // Construct from the captured validated config, not by // rereading settings.serverURL (which may have changed // during disconnect). switch ServerConfig.parse(url) { case .success(let config): self.session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings) case .failure: let fallback = ServerConfig.parse(SettingsStore.defaultServerURL) if case .success(let value) = fallback { self.session = VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings) } else { fatalError("Unreachable: default server URL is valid") } } } self.isReplacingSession = false } } } 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) { // The mic toggle is disabled in text mode to enforce the // privacy invariant at the UI level as well (Finding 4). 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) .disabled(settings.textMode) 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 { $settings.textMode } 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, .tearingDown: 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, .tearingDown: 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 .tearingDown: return "Cleaning up…" 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 // Starting a conversation preserves the restored last-50 history; only // the explicit Clear button erases it. 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()) }