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()) }
@@ -0,0 +1,125 @@
import SwiftUI
/// Hope's square LED presence, matching the web's 21 × 21 dot matrix.
/// One Canvas replaces hundreds of subviews. The adjacent status text is the
/// single accessible description; individual LEDs are never focusable.
struct LightwellView: View {
enum Phase: Equatable {
case idle, connecting, waiting, thinking, tearingDown, failed, readyNoInput
case listening(level: Double)
case speaking(level: Double)
}
let phase: Phase
var compact = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
@Environment(\.scenePhase) private var scenePhase
@Environment(\.dynamicTypeSize) private var typeSize
private var side: CGFloat {
if compact { return typeSize.isAccessibilitySize ? 80 : 112 }
return typeSize.isAccessibilitySize ? 136 : 184
}
private var animated: Bool {
guard !reduceMotion, scenePhase == .active else { return false }
switch phase {
case .connecting, .waiting, .thinking, .listening, .speaking: return true
default: return false
}
}
var body: some View {
Group {
if animated {
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in
matrix(time: timeline.date.timeIntervalSinceReferenceDate)
}
} else {
matrix(time: 0)
}
}
.frame(width: side, height: side)
.background(Nearlight.personaSurface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
.overlay {
RoundedRectangle(cornerRadius: Nearlight.radiusDock)
.strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1)
}
.accessibilityHidden(true)
}
private var tint: Color {
switch phase {
case .failed: return Nearlight.alert
case .tearingDown: return Nearlight.quiet
default: return Nearlight.rosewood
}
}
private func matrix(time: Double) -> some View {
Canvas { context, size in
let spacing = (min(size.width, size.height) - 28) / 20
for row in 0..<21 {
for column in 0..<21 {
let dot = Self.dot(row: row, column: column, phase: phase,
time: time, reduceMotion: reduceMotion)
let diameter = max(1.6, spacing * 0.28) * dot.scale
let center = CGPoint(x: 14 + CGFloat(column) * spacing,
y: 14 + CGFloat(row) * spacing)
let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2,
width: diameter, height: diameter)
// Increased contrast brightens the whole lattice; state is
// encoded in its pattern and adjacent text, never color alone.
let opacity = contrast == .increased ? min(1, dot.opacity * 1.25 + 0.16) : dot.opacity
context.fill(Path(ellipseIn: rect), with: .color(tint.opacity(opacity)))
}
}
}
}
struct Dot: Equatable {
let opacity: Double
let scale: Double
}
/// Deterministic sampling is shared with offline tests. Reduce Motion freezes
/// waves and signal-driven geometry, while keeping each state's pattern clear.
static func dot(row: Int, column: Int, phase: Phase, time: Double, reduceMotion: Bool) -> Dot {
let x = Double(column - 10) / 10.5
let y = Double(row - 10) / 10.5
let radius = sqrt(x * x + y * y)
let t = reduceMotion ? 0 : time
let baseline = max(0.08, 0.20 - radius * 0.075)
let offset = sin(Double(row) * 12.9898 + Double(column) * 78.233)
var brightness = baseline
var scale = 1.0
switch phase {
case .idle, .tearingDown:
break
case .readyNoInput:
brightness += 0.28 * exp(-radius * radius * 3)
case .failed:
// A stable diagonal cross rather than an animated alarm.
if abs(row - column) <= 1 || abs(row + column - 20) <= 1 { brightness = 0.65 }
case .connecting, .waiting:
let ring = exp(-pow((radius - 0.53) * 7, 2))
let sweep = reduceMotion ? 0.75 : 0.55 + 0.45 * cos(atan2(y, x) - t * 2)
brightness += ring * sweep * 0.65
case .thinking:
let bandCenter = reduceMotion ? 0 : sin(t * 1.8) * 0.5
let band = exp(-pow((y - bandCenter) * 4, 2)) * exp(-x * x * 2)
brightness += band * 0.65
case .listening(let raw), .speaking(let raw):
let level = reduceMotion ? 0.35 : (raw.isFinite ? min(max(raw, 0), 1) : 0)
let speaking: Bool
if case .speaking = phase { speaking = true } else { speaking = false }
let signal = min(1, level * 1.3)
let bloom = exp(-radius * radius * (3.0 - signal * 1.5))
let ripple = reduceMotion ? 1 : 0.85 + 0.15 * sin(t * 4 + offset)
brightness += bloom * (0.25 + signal * 0.7) * ripple * (speaking ? 1 : 0.65)
scale += max(0, 1 - radius) * (speaking ? 1.4 : 0.85) * signal
}
return Dot(opacity: min(max(brightness, 0.02), 1), scale: scale)
}
}
@@ -0,0 +1,153 @@
import SwiftUI
struct LoginView: View {
@ObservedObject var auth: AuthStore
@ObservedObject var settings: SettingsStore
@State private var username = ""
@State private var password = ""
@State private var server = ""
@State private var serverError: String?
@State private var savedServer: String?
@State private var passwordVisible = false
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
NavigationStack {
Form {
Section {
VStack(alignment: .leading, spacing: Nearlight.s12) {
Text("Sign in to Hope")
.font(NearlightFont.welcome(typeSize))
.foregroundStyle(Nearlight.ink)
.accessibilityAddTraits(.isHeader)
Text("Use the account provided by your server administrator. Hermes is managed by your server.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, Nearlight.s16)
.listRowBackground(Nearlight.ground)
.listRowSeparator(.hidden)
}
Section("Account") {
TextField("Username", text: $username).textContentType(.username)
.textInputAutocapitalization(.never).autocorrectionDisabled()
HStack {
Group {
if passwordVisible { TextField("Password", text: $password) }
else { SecureField("Password", text: $password) }
}
.textContentType(.password)
Button(passwordVisible ? "Hide" : "Show") { passwordVisible.toggle() }
.accessibilityLabel(passwordVisible ? "Hide password" : "Show password")
}
Button("Sign in") {
let suppliedPassword = password
Task {
await auth.login(username: username, password: suppliedPassword)
if auth.user != nil { password = "" }
}
}
.font(NearlightFont.control(typeSize))
.buttonStyle(.borderedProminent)
.tint(Nearlight.rosewood)
.foregroundStyle(Nearlight.onRosewood)
.frame(minHeight: 44)
.padding(.vertical, Nearlight.s4)
.disabled(auth.isBusy || username.isEmpty || password.isEmpty)
if auth.isBusy { ProgressView("Signing in…").tint(Nearlight.rosewood) }
if let error = auth.error {
Text(error).foregroundStyle(Nearlight.alert)
.fixedSize(horizontal: false, vertical: true)
}
}
.listRowBackground(Nearlight.surface)
Section("Server") {
TextField("HTTPS server address", text: $server).keyboardType(.URL)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Save server address") {
switch ServerConfig.parse(server) {
case .success(let config):
server = config.baseURL.absoluteString
settings.serverURL = config.baseURL.absoluteString
serverError = nil
savedServer = config.baseURL.absoluteString
case .failure(let error): serverError = error.localizedDescription; savedServer = nil
}
}.disabled(auth.isBusy || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || server == settings.serverURL)
if let serverError {
Label(serverError, systemImage: "exclamationmark.circle").foregroundStyle(Nearlight.alert)
.fixedSize(horizontal: false, vertical: true)
} else if savedServer != nil {
Label("Server address saved. Sign in uses this server.", systemImage: "checkmark.circle").font(.footnote).foregroundStyle(Nearlight.quiet)
}
Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.")
.font(.footnote).foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
if auth.config.baseURL.host == TLSPinning.host, auth.config.baseURL.port == 9178 {
Link("Install Hope server certificate in Safari", destination: auth.config.baseURL.appendingPathComponent("hope-server.mobileconfig"))
Text("Install Hope LAN Server Trust in Settings → General → VPN & Device Management. Then enable 192.168.86.2 in Settings → General → About → Certificate Trust Settings. Only trust this certificate for your own server.")
.font(.footnote).foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
}
}
.listRowBackground(Nearlight.surface)
}
.foregroundStyle(Nearlight.ink)
.tint(Nearlight.rosewood)
.scrollContentBackground(.hidden)
.background(Nearlight.ground)
.navigationTitle("Hope")
.toolbarBackground(Nearlight.ground, for: .navigationBar)
.onAppear { server = settings.serverURL }
.onChange(of: server) { _, _ in serverError = nil; savedServer = nil }
}
}
}
struct AuthenticationRoot: View {
@ObservedObject var settings: SettingsStore
@State private var auth: AuthStore
@State private var replacement: Task<Void, Never>?
init(settings: SettingsStore) {
self.settings = settings
_auth = State(initialValue: Self.makeAuth(settings))
}
private static func makeAuth(_ settings: SettingsStore) -> AuthStore {
guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { preconditionFailure("Invalid configured origin") }
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing"), !ProcessInfo.processInfo.arguments.contains("--live-testing") {
return AuthStore(config: config, tokens: MemoryTokenStore(), fixture: true)
}
#endif
return AuthStore(config: config)
}
var body: some View {
RootContent(settings: settings, auth: auth)
.task(id: auth.config.baseURL) { await auth.restore() }
.onChange(of: settings.serverURL) { _, _ in
guard !auth.fixture else { return }
guard replacement == nil else { return }
replacement = Task { @MainActor in
await auth.logout { await auth.disconnectAccount?() }
guard !Task.isCancelled else { return }
// Read latest origin after teardown; intermediate edits never
// restore credentials or connect an intermediate account.
auth = Self.makeAuth(settings)
replacement = nil
}
}
}
}
private struct RootContent: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var auth: AuthStore
var body: some View {
if let user = auth.user {
ConversationView(settings: settings, auth: auth, user: user, bearerToken: auth.token ?? "")
.id(auth.config.baseURL.absoluteString + ":" + user.id)
} else {
LoginView(auth: auth, settings: settings)
}
}
}
@@ -0,0 +1,236 @@
import SwiftUI
/// Nearlight transcript: a legible shared record. Full-width rows, no
/// bubbles; metadata row per event; "Live" placeholder on partials.
struct TranscriptListView: View {
let events: [TranscriptEvent]
var onClear: () -> Void
@State private var confirmClear = false
@State private var followingLatest = true
@State private var selectionActive = false
@State private var viewportHeight: CGFloat = 0
@State private var latestBottom: CGFloat = 0
@AccessibilityFocusState private var focusedEventID: String?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var typeSize
init(events: [TranscriptEvent], onClear: @escaping () -> Void) {
self.events = events
self.onClear = onClear
_followingLatest = State(initialValue: events.isEmpty)
}
/// Auto-follow eligibility: never scroll while the user is selecting
/// text or reading history.
private var isFollowingEligible: Bool {
followingLatest && !selectionActive && focusedEventID == nil
}
private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"#
var body: some View {
ScrollViewReader { proxy in
VStack(spacing: 0) {
HStack {
Text("Conversation")
.font(NearlightFont.support(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.quiet)
Spacer()
Menu {
Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true }
} label: {
Image(systemName: "ellipsis").frame(width: 44, height: 44)
}
.accessibilityLabel("Conversation options")
.disabled(events.isEmpty)
}
.padding(.horizontal, Nearlight.s20)
ScrollView {
LazyVStack(alignment: .leading, spacing: Nearlight.s24) {
if events.isEmpty {
Text("Your conversation will appear here.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
.padding(.top, Nearlight.s24)
}
ForEach(events) { event in transcriptRow(event) }
GeometryReader { geometry in
Color.clear.preference(
key: TranscriptLatestBottomKey.self,
value: geometry.frame(in: .named("transcriptScroll")).maxY
)
}
.frame(height: 1)
.id("latest")
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s16)
.padding(.bottom, Nearlight.s20)
.frame(minHeight: 200, alignment: .topLeading)
}
.coordinateSpace(name: "transcriptScroll")
.background {
GeometryReader { geometry in
Color.clear.preference(
key: TranscriptViewportHeightKey.self,
value: geometry.size.height
)
}
}
.scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.top)
.simultaneousGesture(DragGesture().onChanged { _ in
// Any manual drag suspends automatic following.
followingLatest = false
})
.onChange(of: events.last?.text) { _, _ in
guard isFollowingEligible else { return }
proxy.scrollTo("latest", anchor: .bottom)
}
.onChange(of: events.count) { _, count in
if count == 0 {
selectionActive = false
followingLatest = true
} else if isFollowingEligible {
proxy.scrollTo("latest", anchor: .bottom)
}
}
.onChange(of: selectionActive) { _, active in
// Beginning text selection suspends auto-follow so the
// user's reading position is never disturbed.
if active { followingLatest = false }
}
.onChange(of: focusedEventID) { _, eventID in
// VoiceOver focus on any transcript row means the user is
// reading history. Only Latest resumes automatic following.
if eventID != nil { followingLatest = false }
}
.onPreferenceChange(TranscriptViewportHeightKey.self) { height in
viewportHeight = height
updateFollowingEligibility(bottom: latestBottom, viewport: height)
}
.onPreferenceChange(TranscriptLatestBottomKey.self) { bottom in
latestBottom = bottom
updateFollowingEligibility(bottom: bottom, viewport: viewportHeight)
}
if !followingLatest && !events.isEmpty {
HStack {
Spacer()
Button {
// Explicit Latest restores full eligibility,
// clearing any stale selection suspension.
selectionActive = false
focusedEventID = nil
followingLatest = true
withAnimation(reduceMotion ? nil : Nearlight.standard) {
proxy.scrollTo("latest", anchor: .bottom)
}
} label: {
Label("Latest", systemImage: "arrow.down")
.font(NearlightFont.support(typeSize))
.padding(.horizontal, Nearlight.s12)
.frame(minHeight: 44)
.background(Nearlight.surface, in: Capsule())
.overlay(Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
}
.accessibilityIdentifier("talk.latest")
}
.padding(.horizontal, Nearlight.s12)
.padding(.bottom, Nearlight.s12)
}
}
}
.alert("Clear this conversation?", isPresented: $confirmClear) {
Button("Clear conversation", role: .destructive, action: onClear)
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes the transcript saved on this iPhone. It doesn’t reset Hope’s memory or end your current conversation.")
}
}
private func updateFollowingEligibility(bottom: CGFloat, viewport: CGFloat) {
guard bottom > 0,
viewport > 0,
!selectionActive,
focusedEventID == nil else { return }
let shouldFollow = bottom <= viewport + 48
if followingLatest != shouldFollow {
followingLatest = shouldFollow
}
}
private func transcriptRow(_ event: TranscriptEvent) -> some View {
let isUser = event.role == .user
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespaces)
return VStack(alignment: .leading, spacing: Nearlight.s8) {
// Metadata row: speaker, timestamp trailing. Stacks at
// accessibility sizes per spec.
Group {
if typeSize.isAccessibilitySize {
VStack(alignment: .leading, spacing: Nearlight.s4) {
speakerLabel(isUser)
timeLabel(event)
}
} else {
HStack(spacing: Nearlight.s8) {
speakerLabel(isUser)
timeLabel(event)
}
}
}
Text(text)
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onLongPressGesture(minimumDuration: 0.05, pressing: { pressing in
// Text-selection interaction begins with long-press on the row;
// suspend auto-follow while pressing/held and restore when the
// press ends so following can resume.
selectionActive = pressing
}, perform: {})
.contextMenu {
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
}
.accessibilityElement(children: .combine)
.accessibilityFocused($focusedEventID, equals: event.id)
.id(event.id)
}
private func speakerLabel(_ isUser: Bool) -> some View {
Text(isUser ? "You" : "Hope")
.font(NearlightFont.metadata(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.ink)
}
private func timeLabel(_ event: TranscriptEvent) -> some View {
Group {
if event.isFinal {
Text(event.timestamp, style: .time)
} else {
Text("Live")
}
}
.font(NearlightFont.metadata(typeSize))
.foregroundStyle(Nearlight.quiet)
.monospacedDigit()
}
}
private struct TranscriptViewportHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private struct TranscriptLatestBottomKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
@@ -0,0 +1,89 @@
import SwiftUI
struct ConnectionTestView: View {
@ObservedObject var settings: SettingsStore
var bearerToken: String = ""
@State private var isBusy = false
@State private var result: Result<TokenResponse, Error>?
@State private var urlError: String?
var body: some View {
Form {
Section("Server") {
LabeledContent("URL") {
Text(settings.trimmedServerURL)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
}
if let urlError {
Label(urlError, systemImage: "xmark.octagon")
.foregroundStyle(.red)
}
}
Section {
Button(action: runTest) {
HStack {
Spacer()
if isBusy {
ProgressView()
} else {
Text("Test connection")
}
Spacer()
}
}
.disabled(isBusy || settings.trimmedServerURL.isEmpty)
}
if let result {
switch result {
case .success(let response):
Section("Server access verified") {
LabeledContent("Room") {
Text(response.room)
.font(.footnote.monospaced())
}
LabeledContent("User") {
Text(response.user.displayName ?? response.user.id)
}
Label("Account access and session setup verified. This does not test microphone permission, secure voice transport, or spoken replies.", systemImage: "checkmark.circle")
.foregroundStyle(.green)
Text("Start a conversation to verify live voice and replies.").font(.footnote).foregroundStyle(.secondary)
}
case .failure(let error):
Section("Error") {
Label(error.localizedDescription, systemImage: "xmark.octagon")
.foregroundStyle(.red)
.font(.footnote)
}
}
}
}
.navigationTitle("Connection test")
.navigationBarTitleDisplayMode(.inline)
}
private func runTest() {
result = nil
urlError = nil
switch ServerConfig.parse(settings.trimmedServerURL) {
case .success(let config):
isBusy = true
Task {
let client = TokenClient(config: config, bearerToken: bearerToken)
do {
let response = try await client.fetchToken()
result = .success(response)
} catch {
result = .failure(error)
}
isBusy = false
}
case .failure(let error):
urlError = error.localizedDescription
}
}
}
#Preview {
ConnectionTestView(settings: SettingsStore())
}
@@ -0,0 +1,261 @@
import SwiftUI
struct SettingsView: View {
@ObservedObject var settings: SettingsStore
/// The current voice session, directly observed so the Apply button's
/// disabled state and help text update reactively when the session
/// transitions (e.g. `.tearingDown` → `.failed`).
@ObservedObject var session: VoiceSession
/// Local draft of the server URL. Only written to `settings` when the user
/// explicitly applies a value that passes validation — keystrokes never
/// reach storage or the network layer.
@State private var urlText: String = ""
@State private var urlError: String?
@State private var showVoicePicker = false
@State private var savedURLConfirmation: String?
@State private var pendingServerChange: String?
@FocusState private var urlFocused: Bool
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
self.session = session
}
/// Whether the current session is safe to replace. Apply is permitted for
/// `.idle` (no active call) and `.failed` (fully torn down after failure —
/// the user needs to correct an unreachable server without relaunching).
/// Disabled during `.tearingDown`, `.connecting`, `.waitingAgent`, and
/// `.ready` to avoid disconnecting an active call or racing cleanup.
private var canApplyServerURL: Bool {
session.state == .idle || session.state.isFailed
}
var body: some View {
Form {
Section {
Button {
showVoicePicker = true
} label: {
LabeledContent("Speaking voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
}
Picker("Conversation mode", selection: $settings.textMode) {
Text("Voice").tag(false)
Text("Type · Microphone off").tag(true)
}
} header: {
Text("Make it yours")
} footer: {
Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.")
}
Section {
Text("Server URL")
.font(.footnote.weight(.semibold))
.foregroundStyle(Nearlight.quiet)
TextField(SettingsStore.defaultServerURL, text: $urlText)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Server URL")
.focused($urlFocused)
.onAppear { urlText = settings.serverURL }
.onChange(of: urlText) { _, newValue in
// Keep confirmation across the asynchronous onChange
// caused by successful whitespace normalization.
guard newValue != savedURLConfirmation else { return }
savedURLConfirmation = nil
urlError = nil
}
.overlay(alignment: .trailing) {
if savedURLConfirmation != nil {
Image(systemName: "checkmark.circle")
.foregroundStyle(Nearlight.quiet)
.padding(.trailing, 8)
.accessibilityHidden(true)
}
}
if let urlError {
Label(urlError, systemImage: "exclamationmark.circle")
.font(.footnote)
.foregroundStyle(Nearlight.alert)
.accessibilityIdentifier("settings.server.error")
} else if savedURLConfirmation != nil {
Text("Server address saved.")
.font(.footnote)
.foregroundStyle(Nearlight.quiet)
.accessibilityIdentifier("settings.server.saved")
}
Button("Change server") { requestServerURLChange() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
if !canApplyServerURL {
Text(session.state == .tearingDown
? "Waiting for failure cleanup to finish…"
: "End the conversation before changing servers.")
.font(.footnote)
.foregroundStyle(Nearlight.quiet)
}
NavigationLink("Connection test") {
ConnectionTestView(settings: settings, bearerToken: session.bootstrapBearerToken)
}
} header: {
Text("Connection")
} footer: {
Text("Hope runs on your own server. Your iPhone needs to be on the same network or a network that can reach it.")
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(Nearlight.ground)
.tint(Nearlight.rosewood)
.sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session)
}
.confirmationDialog("Change Hope server?", isPresented: Binding(get: { pendingServerChange != nil }, set: { if !$0 { pendingServerChange = nil } }), titleVisibility: .visible) {
Button("Change server and sign out", role: .destructive) {
if let pendingServerChange { applyServerURL(pendingServerChange) }
pendingServerChange = nil
}
Button("Cancel", role: .cancel) { pendingServerChange = nil }
} message: {
Text("Changing servers signs you out and ends the current session. Conversations and account data remain with the current server.")
}
}
/// Validates the draft and persists it only on success. On failure the
/// draft stays in the field with an inline error; `settings.serverURL` is
/// left untouched so the active session keeps using its original URL.
/// Guarded: only applies when the session is idle (no active call).
private func requestServerURLChange() {
savedURLConfirmation = nil
guard canApplyServerURL else { urlError = "End the conversation before changing servers."; return }
let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { urlError = "Enter a server URL."; return }
switch ServerConfig.parse(trimmed) {
case .success(let config):
pendingServerChange = config.baseURL.absoluteString
urlError = nil
case .failure(let error):
urlError = error.localizedDescription
urlFocused = true
}
}
private func applyServerURL(_ canonicalURL: String) {
settings.serverURL = canonicalURL
urlText = canonicalURL
savedURLConfirmation = canonicalURL
urlError = nil
urlFocused = false
}
}
struct VoicePickerView: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
@Environment(\.dismiss) private var dismiss
@State private var voices = Voices.all
@State private var search = ""
@State private var loading = false
@State private var catalogMessage: String?
@State private var catalogAvailable = false
@State private var requestID = UUID()
@State private var applyingVoiceID: String?
@State private var voiceError: String?
private var filteredVoices: [VoiceOption] {
let query = search.trimmingCharacters(in: .whitespacesAndNewlines)
return voices.filter { query.isEmpty || "\($0.name) \($0.details)".localizedCaseInsensitiveContains(query) }
}
@MainActor private func loadVoices() async {
guard !loading else { return }
loading = true
let id = UUID()
requestID = id
defer { if requestID == id { loading = false } }
do {
guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { throw AuthError.invalidResponse }
let response = try await AuthClient(config: config).voices(token: session.bootstrapBearerToken)
try Task.checkCancellation()
guard requestID == id else { return }
let valid = response.voices.filter { $0.id.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil }
guard !valid.isEmpty else { throw AuthError.invalidResponse }
voices = valid
catalogAvailable = response.catalogAvailable
catalogMessage = response.catalogAvailable ? nil : response.message
} catch is CancellationError {
return
} catch {
guard !Task.isCancelled, requestID == id else { return }
catalogAvailable = false
catalogMessage = "Couldn’t load all English voices. Your current choices remain available. Check your sign-in and server update, then retry."
}
}
var body: some View {
NavigationStack {
List {
Section {
Text("Ava Dragon HD Omni is the recommended female default. Search by name, locale, gender, or model.")
.font(.footnote)
.foregroundStyle(.secondary)
if loading { ProgressView("Loading English voices…") }
if let catalogMessage { Text(catalogMessage).font(.footnote).foregroundStyle(.secondary) }
if let voiceError { Label(voiceError, systemImage: "exclamationmark.circle").font(.footnote).foregroundStyle(.red) }
if !catalogAvailable && !loading {
Button("Retry voice catalog") { Task { await loadVoices() } }
}
}
if filteredVoices.isEmpty {
Text("No matching voices. Try another name or locale.").foregroundStyle(.secondary)
}
ForEach(filteredVoices, id: \.id) { voice in
Button {
guard applyingVoiceID == nil else { return }
applyingVoiceID = voice.id
voiceError = nil
Task {
let applied = await session.selectVoice(voice.id)
applyingVoiceID = nil
if applied { dismiss() }
else { voiceError = "Couldn’t apply this voice to the live conversation. Your previous voice is still selected." }
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(voice.name)
Text(voice.details).font(.caption).foregroundStyle(.secondary)
}
if settings.voiceID == voice.id {
Spacer()
Image(systemName: "checkmark")
.foregroundStyle(Nearlight.rosewood)
}
}
}
.disabled(applyingVoiceID != nil)
.accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : [])
}
}
.navigationTitle("English voices")
.searchable(text: $search, prompt: "Name, locale, gender, or Omni")
.task { await loadVoices() }
.onDisappear { requestID = UUID(); loading = false }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
#Preview {
let previewSettings = SettingsStore()
guard case .success(let previewConfig) = ServerConfig.parse(SettingsStore.defaultServerURL) else {
return AnyView(Text("Invalid default server URL"))
}
let previewSession = VoiceSession(config: previewConfig, tokenClient: TokenClient(config: previewConfig), settings: previewSettings)
return AnyView(NavigationStack {
SettingsView(settings: previewSettings, session: previewSession)
})
}
@@ -0,0 +1,265 @@
import SwiftUI
/// Nearlight Activity: a working log. Plain rows on Ground, no cards;
/// live updates land in place without reordering while reading.
struct TaskPanelView: View {
@ObservedObject var taskStore: TaskStore
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
Group {
if taskStore.sortedTasks.isEmpty {
emptyState
} else {
taskList
}
}
.navigationTitle("Activity")
.background(Nearlight.ground)
}
private var taskList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(taskStore.sortedTasks) { task in
NavigationLink {
TaskDetailView(taskStore: taskStore, taskID: task.id)
} label: {
TaskRowView(task: task)
}
.buttonStyle(.plain)
Divider()
.overlay(Nearlight.separator)
}
}
.padding(.horizontal, Nearlight.s20)
}
}
private var emptyState: some View {
VStack(alignment: .leading, spacing: Nearlight.s8) {
Image(systemName: "checklist")
.font(.system(size: 28))
.foregroundStyle(Nearlight.quiet)
Text("No tasks yet")
.font(NearlightFont.heading(typeSize))
Text("Tasks from your conversations will appear here.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
}
.padding(.top, Nearlight.s32)
.padding(.horizontal, Nearlight.s20)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("activity.empty")
}
}
/// One activity row: status symbol, title, current step/status, chevron.
private struct TaskRowView: View {
let task: TaskItem
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
HStack(spacing: Nearlight.s12) {
Image(systemName: statusSymbol)
.font(.system(size: 20))
.foregroundStyle(statusColor)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: Nearlight.s4) {
Text(statusTitle)
.font(NearlightFont.support(typeSize))
.foregroundStyle(statusColor)
.fixedSize(horizontal: false, vertical: true)
Text(task.description)
.font(NearlightFont.control(typeSize))
.foregroundStyle(Nearlight.ink)
.fixedSize(horizontal: false, vertical: true)
if let latest = latestStep {
Text(latest)
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
}
Text(task.createdAt, style: .time)
.font(NearlightFont.metadata(typeSize))
.foregroundStyle(Nearlight.quiet)
.monospacedDigit()
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Nearlight.quiet)
.accessibilityHidden(true)
}
.padding(.vertical, Nearlight.s16)
.contentShape(Rectangle())
}
private var statusTitle: String {
switch task.status {
case .pending: return "Queued"
case .running: return "In progress"
case .completed: return "Complete"
case .failed: return "Needs attention"
}
}
private var latestStep: String? {
if let error = task.error { return error }
if let result = task.result { return result }
return task.steps.last?.content
}
private var statusSymbol: String {
switch task.status {
case .pending: return "circle.dotted"
case .running: return "arrow.triangle.2.circlepath"
case .completed: return "checkmark.circle"
case .failed: return "exclamationmark.circle"
}
}
private var statusColor: Color {
switch task.status {
case .pending: return Nearlight.quiet
case .running: return Nearlight.rosewood
case .completed: return Nearlight.quiet
case .failed: return Nearlight.alert
}
}
}
/// Native push detail for one task: title, status, steps, result/error.
/// Looks the task up live in the store so removal shows the missing state.
struct TaskDetailView: View {
@ObservedObject var taskStore: TaskStore
let taskID: String
@Environment(\.dynamicTypeSize) private var typeSize
private var task: TaskItem? {
taskStore.tasks[taskID]
}
var body: some View {
Group {
if let task {
TaskDetailContent(task: task)
} else {
Text("This task is no longer available.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
.padding(Nearlight.s20)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(Nearlight.ground)
.navigationTitle("Task")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct TaskDetailContent: View {
let task: TaskItem
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Nearlight.s24) {
VStack(alignment: .leading, spacing: Nearlight.s12) {
Text(task.description)
.font(NearlightFont.heading(typeSize))
.fixedSize(horizontal: false, vertical: true)
Label(statusTitle, systemImage: statusSymbol)
.font(NearlightFont.support(typeSize))
.foregroundStyle(statusColor)
Text(task.createdAt, style: .time)
.font(NearlightFont.metadata(typeSize))
.foregroundStyle(Nearlight.quiet)
.monospacedDigit()
}
VStack(alignment: .leading, spacing: 0) {
Text("Steps")
.font(NearlightFont.heading(typeSize))
.padding(.bottom, Nearlight.s8)
if task.steps.isEmpty {
Text("No steps reported yet.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
} else {
ForEach(task.steps) { step in
VStack(alignment: .leading, spacing: Nearlight.s4) {
if let role = step.role {
Text(roleLabel(role))
.font(NearlightFont.support(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.quiet)
}
Text(step.content ?? "")
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, Nearlight.s16)
}
}
}
if let result = task.result {
Text(result)
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
}
if let error = task.error {
Text(error)
.font(.body)
.foregroundStyle(Nearlight.alert)
.textSelection(.enabled)
}
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s24)
.padding(.bottom, Nearlight.s24)
}
}
private var statusTitle: String {
switch task.status {
case .pending: return "Queued"
case .running: return "In progress"
case .completed: return "Complete"
case .failed: return "Needs attention"
}
}
private var statusSymbol: String {
switch task.status {
case .pending: return "circle.dotted"
case .running: return "arrow.triangle.2.circlepath"
case .completed: return "checkmark.circle"
case .failed: return "exclamationmark.circle"
}
}
private var statusColor: Color {
switch task.status {
case .pending: return Nearlight.quiet
case .running: return Nearlight.rosewood
case .completed: return Nearlight.quiet
case .failed: return Nearlight.alert
}
}
private func roleLabel(_ role: String) -> String {
switch role {
case "thinking": return "Thinking"
case "tool_call": return "Tool"
default: return role
}
}
}
#Preview {
NavigationStack {
TaskPanelView(taskStore: TaskStore())
}
}