Files
hope-voice-api/Sources/Features/Conversation/ConversationView.swift
T
snowspeeder ed329213de Remove embedded music player
The music feature is retired: delete the Media tab, MediaAPI,
MediaPlayerController, PinnedMediaResourceLoader, MediaPanelView, the
media_state control publish, media scope handling in VoiceSession, the
volume setting, and all media tests. App is now Talk + Activity tabs.

Backend media API in hope-voice-api is untouched (still serves the web UI).
2026-09-12 06:26:58 -04:00

338 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()) }