feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s

This commit is contained in:
snowspeeder committed 2026-10-04 07:23:49 -04:00
commit d735ee72fa
102 files changed
+13077 -258

No files matched your search

@@ -0,0 +1,713 @@
import AVFoundation
import SwiftUI
/// Owns one session across all tabs. Changing tabs never ends a conversation.
struct ConversationView: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var auth: AuthStore
let user: AuthUser
let bearerToken: String
@State private var session: VoiceSession
@State private var activeServerURL: String
@State private var pendingServerURL: String?
@State private var isReplacingSession = false
@State private var selectedTab = 2
@StateObject private var chat: ChatStore
init(settings: SettingsStore, auth: AuthStore, user: AuthUser, bearerToken: String) {
self.auth = auth
self.user = user
self.bearerToken = bearerToken
self.settings = settings
let url = settings.trimmedServerURL
_activeServerURL = State(initialValue: url)
_session = State(initialValue: Self.makeSession(url, settings: settings, bearerToken: bearerToken, userID: user.id))
let api: ChatTransport = auth.fixture ? OfflineChatAPI() : ChatAPI(config: auth.config, token: bearerToken)
_chat = StateObject(wrappedValue: ChatStore(api: api))
#if DEBUG
if auth.fixture && !ProcessInfo.processInfo.arguments.contains("--ui-chat") { _selectedTab = State(initialValue: 0) }
#endif
}
private static func makeSession(_ url: String, settings: SettingsStore, bearerToken: String, userID: String) -> 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, bearerToken: bearerToken), settings: settings, userID: userID)
#if DEBUG
if userID == "offline-fixture", 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()
}
if ProcessInfo.processInfo.arguments.contains("--ui-long-transcript") {
for index in 1...30 {
let role: TranscriptEvent.Role = index.isMultiple(of: 2) ? .agent : .user
session.transcriptHandler.receiveDataTranscript(
role: role,
text: "Earlier message \(index): a deliberately long restored transcript row for reading-position verification."
)
}
}
#endif
return session
}
var body: some View {
TabView(selection: $selectedTab) {
NativeChatView(store: chat, talk: { id in
await session.bindConversation(id)
selectedTab = 0
}, logout: {
await auth.logout { chat.close(); await session.disconnect() }
})
.tabItem { Label("Chats", systemImage: "bubble.left.and.text.bubble.right") }
.tag(2)
NavigationStack {
ConversationContent(settings: settings, session: session, displayName: user.displayName ?? user.username)
.toolbar {
settingsLink
ToolbarItem(placement: .topBarLeading) {
Menu {
Button("Sign out", role: .destructive) {
Task { await auth.logout { chat.close(); await session.disconnect() } }
}
} label: {
Image(systemName: "person.crop.circle")
.frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Account")
}
}
}
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
.tag(0)
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
.tag(1)
}
.tint(HopeTheme.color(.accent, palette: HopePalette(rawValue: chat.preferences.palette) ?? .opal))
.preferredColorScheme(chat.preferences.appearance == "system" ? nil : (chat.preferences.appearance == "dark" ? .dark : .light))
.onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) }
}
.onChange(of: settings.serverURL) { _, value in
if auth.fixture { replaceServer(value) }
}
.onAppear {
auth.disconnectAccount = { [weak session, weak chat] in chat?.close(); await session?.disconnect() }
let unauthorized: () async -> Void = { [weak auth, weak session, weak chat] in
await auth?.logout { chat?.close(); await session?.disconnect() }
}
session.onUnauthorized = unauthorized
chat.onUnauthorized = unauthorized
chat.features.onUnauthorized = unauthorized
}
.disabled(auth.isBusy || auth.token == nil)
}
@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, bearerToken: bearerToken, userID: user.id)
}
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
let displayName: String
@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
@Environment(\.colorSchemeContrast) private var contrast
init(settings: SettingsStore, session: VoiceSession, displayName: String) {
self.settings = settings
self.session = session
self.displayName = displayName
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 let activity = session.assistantActivity {
Label(activity, systemImage: "clock").font(.callout)
.padding().accessibilityIdentifier("assistant.progress")
}
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) { old, new in
// Success only on the transition INTO readiness, not out.
!old && new
}
.sensoryFeedback(.error, trigger: session.state.isFailed) { old, new in
// Error only on entering failure, not leaving it.
!old && new
}
}
// MARK: Welcome (pre-connection, no history)
private var welcome: some View {
ScrollView {
VStack(spacing: Nearlight.s16) {
VStack(spacing: Nearlight.s8) {
Text("A little room for you,")
.font(.largeTitle.weight(.regular))
Text(greetingName + ".")
.font(.system(.largeTitle, design: .serif).italic())
.foregroundStyle(Nearlight.rosewood)
Text("No perfect words needed. Start anywhere.")
.font(.subheadline)
.foregroundStyle(Nearlight.quiet)
}
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Nearlight.s24)
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
.padding(.top, Nearlight.s8)
Text(statusTitle)
.font(.footnote)
.foregroundStyle(Nearlight.rosewood)
.accessibilityIdentifier("talk.status")
if !typeSize.isAccessibilitySize {
VStack(spacing: Nearlight.s8) {
HStack(spacing: Nearlight.s8) {
prompt("Talk through my day", draft: "Help me talk through my day.")
prompt("Explore an idea", draft: "Help me explore an idea.")
}
prompt("Make a plan", draft: "Help me make a plan.")
}
.padding(.top, Nearlight.s8)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Nearlight.s20)
.padding(.bottom, Nearlight.s24)
}
}
private var greetingName: String {
displayName.split(separator: " ").first.map(String.init) ?? displayName
}
private func prompt(_ title: String, draft: String) -> some View {
Button {
// Prepare an editable draft, never submit a prompt or enable capture implicitly.
composerText = draft
settings.textMode = true
composerFocused = true
} label: {
Text(title)
.font(.footnote)
.padding(.horizontal, Nearlight.s16)
.frame(minHeight: 44)
.background(Nearlight.selectedWash, in: Capsule())
.foregroundStyle(Nearlight.rosewood)
}
.accessibilityHint("Prepares a message you can edit. Connect before sending.")
}
/// 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 {
VStack(spacing: Nearlight.s12) {
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true)
VStack(alignment: .center, 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)
}
}
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Nearlight.s20)
.padding(.vertical, Nearlight.s12)
.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 {
guard session.state == .ready, let micError = session.micError else {
return false
}
return AVAudioApplication.shared.recordPermission == .denied
|| micError.localizedCaseInsensitiveContains("denied")
}
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 {
if settings.textMode { draftPreview }
startControls
}
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s12)
.padding(.bottom, Nearlight.s12)
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
.padding(.horizontal, Nearlight.s16)
.padding(.bottom, Nearlight.s8)
}
@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 && contrast == .increased ? Nearlight.surface : selected ? Nearlight.selectedWash : Nearlight.surface,
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.overlay(
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
.strokeBorder(selected && contrast == .increased ? Nearlight.rosewood : selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet,
lineWidth: selected && contrast == .increased ? 2 : 1)
)
}
.accessibilityIdentifier(id)
.accessibilityAddTraits(selected ? .isSelected : [])
}
private var draftPreview: some View {
TextField("What’s on your mind?", text: $composerText, axis: .vertical)
.lineLimit(1...3)
.font(.body)
.padding(Nearlight.s12)
.background(Nearlight.selectedWash, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.focused($composerFocused)
.accessibilityLabel("Message Hope")
}
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("What’s on your mind?", 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 || !session.isReady)
.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? { session.micError ?? 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 { AuthenticationRoot(settings: SettingsStore()) }