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