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 _activeServerURL = State(initialValue: settings.trimmedServerURL) _session = State(initialValue: Self.makeSession(settings.trimmedServerURL, settings: settings)) } private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession { guard case .success(let config) = ServerConfig.parse(url) else { preconditionFailure("SettingsStore must supply a validated server URL") } 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: "waveform") } .tag(0) ActivityTab(taskStore: session.taskStore, settings: settings, session: session) .tag(1) } .id(sessionID) .tint(HopeStyle.tint) .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 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) } } 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 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 } else { connectionStatus.padding(.horizontal, 20).padding(.vertical, 12) TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll) } } .background(Color(.systemBackground)) .navigationTitle("Hope") .safeAreaInset(edge: .bottom, spacing: 0) { conversationControls } .onChange(of: session.state) { _, state in if state == .ready && settings.textMode { composerFocused = true } } } private var welcome: some View { ScrollView { VStack(alignment: .leading, spacing: 28) { OrbView(state: orbState).padding(.top, 24).accessibilityHidden(true) VStack(alignment: .leading, spacing: 12) { Text("A little help.\nA good conversation.") .font(.largeTitle.weight(.semibold)) .fixedSize(horizontal: false, vertical: true) Text("Talk things through, find something out, or get something done.") .font(.body).foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 16) { Label("Think out loud", systemImage: "bubble.left.and.bubble.right") Label("Find an answer", systemImage: "sparkle.magnifyingglass") } .font(.subheadline).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24).padding(.bottom, 24) } } private var connectionStatus: some View { HStack(spacing: 12) { Image(systemName: statusSymbol).font(.title3) .foregroundStyle(session.state.isFailed ? Color.red : HopeStyle.tint) .frame(width: 32, height: 36).accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { Text(statusTitle).font(.headline) Text(statusDetail).font(.subheadline).foregroundStyle(.secondary) } Spacer(minLength: 0) if session.isReady && !settings.textMode && !session.isMuted { ProgressView(value: min(conversation.micLevel * 2.5, 1)) .frame(width: 46).accessibilityLabel("Microphone level") } } .accessibilityElement(children: .combine) } private var conversationControls: some View { VStack(spacing: 12) { if let failure = failureMessage { VStack(alignment: .leading, spacing: 6) { Label("Couldn’t connect to Hope", systemImage: "wifi.exclamationmark") .font(.subheadline.weight(.semibold)) Text(failure).font(.footnote).foregroundStyle(.secondary) NavigationLink("Check connection settings") { SettingsView(settings: settings, session: session) } .font(.subheadline).frame(minHeight: 44) } .frame(maxWidth: .infinity, alignment: .leading) } if session.isReconnecting { Label("Reconnecting… Your conversation is still here.", systemImage: "wifi.exclamationmark") .font(.footnote) } if let error = session.micError, !settings.textMode { VStack(alignment: .leading, spacing: 4) { Text(error).font(.footnote) Button("Switch to typing") { settings.textMode = true }.frame(minHeight: 44) } .frame(maxWidth: .infinity, alignment: .leading) } if let sendError { Text(sendError).font(.footnote).foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .leading) } Picker("Conversation mode", selection: $settings.textMode) { Label("Voice", systemImage: "mic").tag(false) Label("Type", systemImage: "keyboard").tag(true) } .pickerStyle(.segmented).disabled(isBusy) if session.isReady { if settings.textMode { textComposer } else { HStack(spacing: 12) { Button { Task { await session.setMuted(!session.isMuted) } } label: { Label(session.isMuted ? "Unmute" : "Mute", systemImage: session.isMuted ? "mic.slash.fill" : "mic.fill") .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(.bordered) endButton } } } else { Button { sendError = nil Task { await session.connect() } } label: { HStack(spacing: 10) { if isBusy { ProgressView().tint(.white) } else { Image(systemName: settings.textMode ? "bubble.left.fill" : "waveform") } Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : settings.textMode ? "Start chatting" : "Let’s talk") .font(.headline) } .frame(maxWidth: .infinity, minHeight: 48) } .buttonStyle(.borderedProminent).disabled(isBusy) Text(settings.textMode ? "Your microphone stays off." : "Your microphone turns on when you connect.") .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center) } } .padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 16) .background(.bar) } private var textComposer: some View { VStack(spacing: 6) { HStack(alignment: .bottom, spacing: 8) { TextField("Message Hope", text: $composerText, axis: .vertical) .lineLimit(1...5).padding(12) .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) .focused($composerFocused).disabled(isSending) .accessibilityLabel("Message Hope") Button(action: sendComposer) { if isSending { ProgressView().frame(width: 44, height: 44) } else { Image(systemName: "arrow.up.circle.fill").font(.title).frame(width: 44, height: 44) } } .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: "phone.down.fill") .font(.subheadline.weight(.medium)).frame(minHeight: 44) } .buttonStyle(.bordered) } 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 statusTitle: String { switch session.state { case .idle: return "Ready when you are" case .connecting: return "Connecting…" case .waitingAgent: return "Getting Hope ready…" case .tearingDown: return "Ending conversation…" case .failed: return "Connection unavailable" case .ready: if settings.textMode { return "You’re chatting with Hope" } if session.isMuted { return "Microphone muted" } switch conversation.phase { case .thinking: return "Thinking it through…" case .speaking: return "Hope is speaking" default: return "I’m listening" } } } private var statusDetail: String { if !session.isReady { return "Your recent conversation is below." } if settings.textMode { return "Microphone off · Type whenever you’re ready" } if session.isMuted { return "You can still hear Hope." } return conversation.phase == .speaking ? "You can interrupt at any time." : "Speak naturally. Take your time." } private var statusSymbol: String { session.state.isFailed ? "wifi.exclamationmark" : !session.isReady ? "bubble.left.and.bubble.right" : settings.textMode ? "keyboard" : session.isMuted ? "mic.slash" : "waveform" } private var orbState: OrbView.OrbState { if session.state.isFailed { return .failed } return isBusy ? .connecting : .idle } 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 } } } #Preview { ConversationView(settings: SettingsStore()) }