feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s
Rebuild & Deploy / rebuild (push) Failing after 23s
This commit is contained in:
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user