wip: Nearlight redesign implementation

This commit is contained in:
snowspeeder committed 2026-09-12 08:14:06 -04:00
1 parent c76afbd11e
commit fa21c81787
9 files changed
+904 -273

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
import SwiftUI
import UIKit
/// Nearlight design tokens (design spec: local://ui-design-spec.md).
///
/// Views reference semantic roles; the primitive hex values live only here.
/// All custom surfaces are opaque; native system materials are untouched.
enum Nearlight {
// MARK: Palette (light / dark variants)
/// Screen background.
static let ground = Color(light: UIColor(red: 0.957, green: 0.949, blue: 0.965, alpha: 1),
dark: UIColor(red: 0.110, green: 0.098, blue: 0.125, alpha: 1))
/// Controls, grouped rows, dock.
static let surface = Color(light: .white,
dark: UIColor(red: 0.157, green: 0.137, blue: 0.180, alpha: 1))
/// Primary text.
static let ink = Color(light: UIColor(red: 0.161, green: 0.137, blue: 0.184, alpha: 1),
dark: UIColor(red: 0.965, green: 0.937, blue: 0.969, alpha: 1))
/// Supporting text, inactive icons.
static let quiet = Color(light: UIColor(red: 0.396, green: 0.365, blue: 0.424, alpha: 1),
dark: UIColor(red: 0.749, green: 0.698, blue: 0.776, alpha: 1))
/// Action tint, selection, Lightwell.
static let rosewood = Color(light: UIColor(red: 0.529, green: 0.278, blue: 0.380, alpha: 1),
dark: UIColor(red: 0.890, green: 0.635, blue: 0.737, alpha: 1))
/// Errors and destructive actions.
static let alert = Color(light: UIColor(red: 0.675, green: 0.208, blue: 0.282, alpha: 1),
dark: UIColor(red: 1.0, green: 0.604, blue: 0.675, alpha: 1))
// MARK: Derived roles
/// Decorative separator: Ink at 14% over its parent.
static var separator: Color { ink.opacity(0.14) }
/// Selected wash: Rosewood at 10% over Surface.
static var selectedWash: Color { rosewood.opacity(0.10) }
/// Error wash: Alert at 8% over Surface.
static var errorWash: Color { alert.opacity(0.08) }
/// Primary-action label: Surface in light, Ground in dark.
static var onRosewood: Color {
UITraitCollection.current.userInterfaceStyle == .dark ? ground : surface
}
// MARK: Spacing scale
static let s4: CGFloat = 4
static let s8: CGFloat = 8
static let s12: CGFloat = 12
static let s16: CGFloat = 16
static let s20: CGFloat = 20
static let s24: CGFloat = 24
static let s32: CGFloat = 32
static let s48: CGFloat = 48
// MARK: Radius scale
static let radiusSegment: CGFloat = 8
static let radiusControl: CGFloat = 12
static let radiusDock: CGFloat = 16
// MARK: Motion tokens
/// quick = 0.12s easeOut.
static let quick: Animation = .easeOut(duration: 0.12)
/// standard = 0.22s timingCurve(0.22, 1, 0.36, 1).
static let standard: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.22)
/// settle = 0.32s timingCurve(0.22, 1, 0.36, 1).
static let settle: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.32)
/// cycle = 2.4s easeInOut (Lightwell pulses).
static let cycle: Animation = .easeInOut(duration: 2.4)
}
extension Color {
/// Builds an adaptive color from explicit light/dark UIColors without
/// requiring an asset catalog.
init(light: UIColor, dark: UIColor) {
self.init(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
})
}
}
/// Shared typography roles from the spec. SF Pro default design except the
/// rounded welcome heading and wordmark.
enum NearlightFont {
/// Welcome heading: 32 medium, rounded, largeTitle metrics.
static func welcome(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.largeTitle, base: 32, weight: .medium, design: .rounded, sizeCategory: sizeCategory)
}
/// "Hope" wordmark: 20 semibold, rounded, title3 metrics.
static func wordmark(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.title3, base: 20, weight: .semibold, design: .rounded, sizeCategory: sizeCategory)
}
/// Section/detail heading: 22 semibold, title2 metrics.
static func heading(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.title2, base: 22, weight: .semibold, sizeCategory: sizeCategory)
}
/// Control/row label: 17 semibold, body metrics.
static func control(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.body, base: 17, weight: .semibold, sizeCategory: sizeCategory)
}
/// Status/support: 15 regular, subheadline metrics.
static func support(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.subheadline, base: 15, weight: .regular, sizeCategory: sizeCategory)
}
/// Speaker/timestamp: 13 medium, footnote metrics.
static func metadata(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.footnote, base: 13, weight: .medium, sizeCategory: sizeCategory)
}
/// Scales a base size with the given text style via UIFontMetrics and
/// returns a SwiftUI font with the requested weight/design.
private static func scaled(_ textStyle: UIFont.TextStyle, base: CGFloat, weight: Font.Weight,
design: Font.Design = .default, sizeCategory: DynamicTypeSize) -> Font {
// UIFontMetrics scales the base size against the CURRENT Dynamic Type
// setting at render time (no explicit traits: inherits environment).
let scaledSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: base)
return .system(size: scaledSize, weight: weight, design: design)
}
}
@@ -12,13 +12,14 @@ struct ConversationView: View {
init(settings: SettingsStore) {
self.settings = settings
_activeServerURL = State(initialValue: settings.trimmedServerURL)
_session = State(initialValue: Self.makeSession(settings.trimmedServerURL, settings: settings))
let url = settings.trimmedServerURL
_activeServerURL = State(initialValue: url)
_session = State(initialValue: Self.makeSession(url, settings: settings))
}
private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession {
guard case .success(let config) = ServerConfig.parse(url) else {
preconditionFailure("SettingsStore must supply a validated server URL")
preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.")
}
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
#if DEBUG
@@ -37,14 +38,14 @@ struct ConversationView: View {
ConversationContent(settings: settings, session: session)
.toolbar { settingsLink }
}
.tabItem { Label("Talk", systemImage: "waveform") }
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
.tag(0)
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
.tag(1)
}
.id(sessionID)
.tint(HopeStyle.tint)
.tint(Nearlight.rosewood)
.onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) }
}
@@ -107,6 +108,28 @@ private struct ActivityTab: View {
}
}
/// 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:
if textMode { return .readyNoInput }
if session.isMuted { return .readyNoInput }
switch conversation.phase {
case .speaking: return .speaking(level: conversation.agentLevel)
case .thinking: return .thinking
default: return .listening(level: conversation.micLevel)
}
}
}
private struct ConversationContent: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
@@ -116,6 +139,8 @@ private struct ConversationContent: View {
@State private var sendError: String?
@State private var isSending = false
@FocusState private var composerFocused: Bool
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
@@ -128,140 +153,341 @@ private struct ConversationContent: View {
VStack(spacing: 0) {
if transcripts.events.isEmpty && !session.isReady {
welcome
if failureNotice != nil { failureBanner }
} else {
connectionStatus.padding(.horizontal, 20).padding(.vertical, 12)
statusStrip
if session.isReconnecting { reconnectBanner }
if failureNotice != nil { failureBanner }
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
}
}
.background(Color(.systemBackground))
.background(Nearlight.ground)
.navigationTitle("Hope")
.safeAreaInset(edge: .bottom, spacing: 0) { conversationControls }
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom, spacing: 0) { dock }
.onChange(of: session.state) { _, state in
if state == .ready && settings.textMode { composerFocused = true }
}
.sensoryFeedback(.success, trigger: session.state == .ready ? true : false) { _, _ in true }
.sensoryFeedback(.error, trigger: session.state.isFailed) { _, _ in true }
}
// MARK: Welcome (pre-connection, no history)
private var welcome: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
OrbView(state: orbState).padding(.top, 24).accessibilityHidden(true)
VStack(alignment: .leading, spacing: 12) {
Text("A little help.\nA good conversation.")
.font(.largeTitle.weight(.semibold))
VStack(alignment: .leading, spacing: Nearlight.s24) {
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
.padding(.top, Nearlight.s32)
VStack(alignment: .leading, spacing: Nearlight.s12) {
Text("A moment with Hope.")
.font(NearlightFont.welcome(typeSize))
.fixedSize(horizontal: false, vertical: true)
Text("Talk things through, find something out, or get something done.")
.font(.body).foregroundStyle(.secondary)
Text("Speak or type to your own assistant.")
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
}
VStack(alignment: .leading, spacing: 16) {
Label("Think out loud", systemImage: "bubble.left.and.bubble.right")
Label("Find an answer", systemImage: "sparkle.magnifyingglass")
}
.font(.subheadline).foregroundStyle(.secondary)
statusLine
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24).padding(.bottom, 24)
.padding(.horizontal, Nearlight.s20)
.padding(.bottom, Nearlight.s24)
}
}
private var connectionStatus: some View {
HStack(spacing: 12) {
Image(systemName: statusSymbol).font(.title3)
.foregroundStyle(session.state.isFailed ? Color.red : HopeStyle.tint)
.frame(width: 32, height: 36).accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(statusTitle).font(.headline)
Text(statusDetail).font(.subheadline).foregroundStyle(.secondary)
/// Status text 24 below the supporting copy on welcome.
private var statusLine: some View {
Text(statusTitle)
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
.padding(.top, Nearlight.s24)
.accessibilityIdentifier("talk.status")
}
// MARK: Status strip (active conversation)
private var statusStrip: some View {
HStack(spacing: Nearlight.s12) {
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true)
VStack(alignment: .leading, spacing: 2) {
Text(statusTitle)
.font(NearlightFont.control(typeSize))
.foregroundStyle(Nearlight.ink)
.accessibilityIdentifier("talk.status")
if showStatusDetail {
Text(statusDetail)
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
}
}
Spacer(minLength: 0)
if session.isReady && !settings.textMode && !session.isMuted {
ProgressView(value: min(conversation.micLevel * 2.5, 1))
.frame(width: 46).accessibilityLabel("Microphone level")
}
}
.accessibilityElement(children: .combine)
.padding(.horizontal, Nearlight.s20)
.padding(.vertical, Nearlight.s12)
.frame(minHeight: 60, alignment: .leading)
.background(Nearlight.ground)
}
private var conversationControls: some View {
VStack(spacing: 12) {
if let failure = failureMessage {
VStack(alignment: .leading, spacing: 6) {
Label("Couldn’t connect to Hope", systemImage: "wifi.exclamationmark")
.font(.subheadline.weight(.semibold))
Text(failure).font(.footnote).foregroundStyle(.secondary)
NavigationLink("Check connection settings") {
SettingsView(settings: settings, session: session)
/// 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(.subheadline).frame(minHeight: 44)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if session.isReconnecting {
Label("Reconnecting… Your conversation is still here.", systemImage: "wifi.exclamationmark")
.font(.footnote)
}
if let error = session.micError, !settings.textMode {
VStack(alignment: .leading, spacing: 4) {
Text(error).font(.footnote)
Button("Switch to typing") { settings.textMode = true }.frame(minHeight: 44)
}
.frame(maxWidth: .infinity, alignment: .leading)
.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 {
(session.micError?.contains("Microphone") ?? false) && session.state.isFailed == false && session.state == .ready
}
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 ?? ""
if raw.lowercased().contains("agent") {
return "Check that Hope is running on your server."
}
if raw.lowercased().contains("timed out") || raw.lowercased().contains("connect") {
return "Check your Wi-Fi or VPN and make sure your server is running."
}
return raw.isEmpty ? "Check your Wi-Fi or VPN and make sure your server is running." : raw
}
// MARK: Dock
private var dock: some View {
VStack(spacing: Nearlight.s12) {
if let sendError {
Text(sendError).font(.footnote).foregroundStyle(.red)
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 {
startControls
}
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s12)
.padding(.bottom, Nearlight.s12)
.background(
// Dock: Surface, 16-point top corners, decorative top separator.
VStack(spacing: 0) {
Rectangle().fill(Nearlight.separator).frame(height: 1 / UIScreen.main.scale)
Nearlight.surface
}
.ignoresSafeArea(.container, edges: .bottom)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: Nearlight.radiusDock, topTrailingRadius: Nearlight.radiusDock))
)
}
@ViewBuilder private var modeSelector: some View {
if typeSize.isAccessibilitySize {
// Two stacked 52-point selection buttons at accessibility sizes.
VStack(spacing: Nearlight.s8) {
modeButton("Voice", systemImage: "mic", selected: !settings.textMode, id: "talk.mode.voice") {
settings.textMode = false
}
modeButton("Type", systemImage: "keyboard", selected: settings.textMode, id: "talk.mode.type") {
settings.textMode = true
}
}
.disabled(isBusy)
} else {
Picker("Conversation mode", selection: $settings.textMode) {
Label("Voice", systemImage: "mic").tag(false)
Label("Type", systemImage: "keyboard").tag(true)
}
.pickerStyle(.segmented).disabled(isBusy)
if session.isReady {
if settings.textMode { textComposer }
else {
HStack(spacing: 12) {
Button {
Task { await session.setMuted(!session.isMuted) }
} label: {
Label(session.isMuted ? "Unmute" : "Mute", systemImage: session.isMuted ? "mic.slash.fill" : "mic.fill")
.frame(maxWidth: .infinity, minHeight: 44)
}
.buttonStyle(.bordered)
endButton
}
}
} else {
Button {
sendError = nil
Task { await session.connect() }
} label: {
HStack(spacing: 10) {
if isBusy { ProgressView().tint(.white) }
else { Image(systemName: settings.textMode ? "bubble.left.fill" : "waveform") }
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : settings.textMode ? "Start chatting" : "Let’s talk")
.font(.headline)
}
.frame(maxWidth: .infinity, minHeight: 48)
}
.buttonStyle(.borderedProminent).disabled(isBusy)
Text(settings.textMode ? "Your microphone stays off." : "Your microphone turns on when you connect.")
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
.pickerStyle(.segmented)
.disabled(isBusy)
}
.padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 16)
.background(.bar)
}
private var textComposer: some View {
VStack(spacing: 6) {
HStack(alignment: .bottom, spacing: 8) {
private func modeButton(_ title: String, systemImage: String, selected: Bool,
id: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: Nearlight.s8) {
Image(systemName: systemImage)
Text(title)
if selected { Spacer(); Image(systemName: "checkmark") }
}
.font(NearlightFont.control(typeSize))
.foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink)
.frame(maxWidth: .infinity, minHeight: 52)
.background(selected ? Nearlight.selectedWash : Nearlight.surface,
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.overlay(
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
.strokeBorder(selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet, lineWidth: 1)
)
}
.accessibilityIdentifier(id)
.accessibilityAddTraits(selected ? .isSelected : [])
}
private var startControls: some View {
VStack(spacing: Nearlight.s8) {
Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.")
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
Button {
sendError = nil
Task { await session.connect() }
} label: {
HStack(spacing: Nearlight.s8) {
if isBusy {
ProgressView().tint(Nearlight.onRosewood)
} else {
Image(systemName: settings.textMode ? "keyboard" : "mic")
}
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : "Start chatting")
.font(NearlightFont.control(typeSize))
}
.frame(maxWidth: .infinity, minHeight: 52)
.background(isBusy ? Nearlight.rosewood.opacity(0.6) : Nearlight.rosewood,
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.foregroundStyle(Nearlight.onRosewood)
}
.disabled(isBusy)
.accessibilityIdentifier("start.chatting")
}
}
private var voiceControls: some View {
Group {
if typeSize.isAccessibilitySize {
VStack(spacing: Nearlight.s12) {
HStack(spacing: Nearlight.s12) {
muteButton
micMeter
}
endButton
}
} else {
HStack(spacing: Nearlight.s12) {
muteButton
micMeter
endButton
}
}
}
}
private var muteButton: some View {
Button {
Task { await session.setMuted(!session.isMuted) }
} label: {
Image(systemName: session.isMuted ? "mic.slash" : "mic")
.font(.system(size: 20, weight: .medium))
.frame(width: 52, height: 52)
.background(session.isMuted ? Nearlight.selectedWash : Nearlight.surface,
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.overlay(
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
.strokeBorder(Nearlight.quiet, lineWidth: 1)
)
.foregroundStyle(session.isMuted ? Nearlight.rosewood : Nearlight.ink)
}
.accessibilityLabel(session.isMuted ? "Unmute microphone" : "Mute microphone")
.accessibilityIdentifier("talk.microphone.toggle")
.sensoryFeedback(.selection, trigger: session.isMuted)
}
/// Five-bar capsule meter, 64×24, leading aligned.
private var micMeter: some View {
HStack(spacing: 6) {
let level = session.isMuted || settings.textMode ? 0.0 : conversation.micLevel
let filled = Int(ceil(level * 5))
ForEach(0..<5, id: \.self) { index in
Capsule()
.fill(index < filled ? Nearlight.rosewood : Color.clear)
.overlay(index < filled ? nil : Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
.frame(width: 6, height: [6.0, 12.0, 24.0, 12.0, 6.0][index])
}
}
.frame(width: 64, height: 24, alignment: .leading)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Microphone level")
.accessibilityValue(session.isMuted || settings.textMode ? "Off" : "\(Int(round(conversation.micLevel * 100)))%")
}
private var typeControls: some View {
VStack(spacing: Nearlight.s12) {
HStack(alignment: .bottom, spacing: Nearlight.s8) {
TextField("Message Hope", text: $composerText, axis: .vertical)
.lineLimit(1...5).padding(12)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
.focused($composerFocused).disabled(isSending)
.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: 44, height: 44) }
else { Image(systemName: "arrow.up.circle.fill").font(.title).frame(width: 44, height: 44) }
if isSending {
ProgressView().frame(width: 52, height: 52)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 20, weight: .medium))
.frame(width: 52, height: 52)
.background(Nearlight.rosewood, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.foregroundStyle(Nearlight.onRosewood)
}
}
.disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting)
.accessibilityLabel("Send message")
@@ -275,12 +501,22 @@ private struct ConversationContent: View {
composerFocused = false
Task { await session.disconnect() }
} label: {
Label("End conversation", systemImage: "phone.down.fill")
.font(.subheadline.weight(.medium)).frame(minHeight: 44)
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)
}
.buttonStyle(.bordered)
.accessibilityIdentifier("end.conversation")
}
// MARK: Status copy
private var isBusy: Bool {
session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown
}
@@ -288,36 +524,36 @@ private struct ConversationContent: View {
if case .failed(let message) = session.state { return message }
return nil
}
private var failureNotice: String? { failureMessage }
private var statusTitle: String {
switch session.state {
case .idle: return "Ready when you are"
case .connecting: return "Connecting…"
case .waitingAgent: return "Getting Hope ready…"
case .connecting: return "Connecting to your server…"
case .waitingAgent: return "Waiting for Hope…"
case .tearingDown: return "Ending conversation…"
case .failed: return "Connection unavailable"
case .failed: return "Connection interrupted"
case .ready:
if settings.textMode { return "You’re chatting with Hope" }
if session.isMuted { return "Microphone muted" }
if settings.textMode { return "Ready for your message" }
if session.isMuted { return "Microphone off" }
switch conversation.phase {
case .thinking: return "Thinking it through…"
case .listening: return "Listening"
case .thinking: return "Thinking…"
case .speaking: return "Hope is speaking"
default: return "I’m listening"
default: return "Listening"
}
}
}
private var statusDetail: String {
if !session.isReady { return "Your recent conversation is below." }
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 conversation.phase == .speaking ? "You can interrupt at any time." : "Speak naturally. Take your time."
}
private var statusSymbol: String {
session.state.isFailed ? "wifi.exclamationmark" : !session.isReady ? "bubble.left.and.bubble.right" : settings.textMode ? "keyboard" : session.isMuted ? "mic.slash" : "waveform"
}
private var orbState: OrbView.OrbState {
if session.state.isFailed { return .failed }
return isBusy ? .connecting : .idle
return ""
}
private func sendComposer() {
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isSending else { return }
@@ -335,4 +571,15 @@ private struct ConversationContent: View {
}
}
extension VoiceSession {
/// Whether any connection reached ready during this session lifetime.
/// Used to title the failure notice per spec.
var everConnected: Bool {
// Sessions are recreated on replacement; reaching ready is recorded
// indirectly through transcript presence. Conservative: treat any
// prior transcript activity as evidence of a completed connection.
!transcriptHandler.events.isEmpty
}
}
#Preview { ConversationView(settings: SettingsStore()) }
@@ -0,0 +1,122 @@
import SwiftUI
import UIKit
/// The Nearlight signature element: an upright, softly lit aperture.
///
/// Two capsules in a ZStack — no assets, shaders, blur, or particles. The
/// inner capsule carries a vertical Rosewood gradient; its geometry encodes
/// conversation state and audio level. Noninteractive and accessibility
/// hidden: the companion status text carries meaning.
struct LightwellView: View {
/// Lightwell presentation state, mapped from session/conversation models.
enum Phase: Equatable {
case idle
case connecting
case waiting
case listening(level: Double)
case thinking
case speaking(level: Double)
case tearingDown
case failed
/// Input never drives the light (muted or Type mode).
case readyNoInput
}
let phase: Phase
/// Compact (24×36) in the conversation status strip; full (64×96) on welcome.
var compact: Bool = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Pending-state pulse driver, 0 → 1 → 0 over the 2.4s cycle.
@State private var pulse: Double = 0
/// Frame dimensions never animate; inner dimensions multiply by 0.375
/// in the compact frame.
private var frameSize: CGSize {
compact ? CGSize(width: 24, height: 36) : CGSize(width: 64, height: 96)
}
private var baseInner: CGSize {
let base = CGSize(width: 16, height: 40)
return compact
? CGSize(width: base.width * 0.375, height: base.height * 0.375)
: base
}
var body: some View {
let inner = innerSize
ZStack {
Capsule()
.fill(Nearlight.surface)
Capsule()
.fill(
LinearGradient(
stops: [
.init(color: Nearlight.rosewood.opacity(0.35), location: 0),
.init(color: Nearlight.rosewood.opacity(1), location: 0.5),
.init(color: Nearlight.rosewood.opacity(0.55), location: 1),
],
startPoint: .top, endPoint: .bottom
)
)
.frame(width: inner.width, height: inner.height)
.opacity(overallOpacity)
.animation(reduceMotion ? nil : Nearlight.quick, value: overallOpacity)
.animation(reduceMotion ? nil : Nearlight.settle, value: innerSize)
}
.frame(width: frameSize.width, height: frameSize.height)
.overlay(
Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1)
)
.accessibilityHidden(true)
.task(id: pulseActive) {
guard pulseActive else {
pulse = 0
return
}
while !Task.isCancelled {
withAnimation(reduceMotion ? nil : Nearlight.cycle) { pulse = pulse == 0 ? 1 : 0 }
try? await Task.sleep(for: .seconds(2.4))
}
}
}
/// Whether the pulse loop should run for this phase.
private var pulseActive: Bool {
switch phase {
case .connecting, .waiting, .thinking: return !reduceMotion
default: return false
}
}
/// Inner capsule geometry per spec table. `a` is the smoothed level.
private var innerSize: CGSize {
let base = baseInner
switch phase {
case .listening(let a):
let level = min(max(a, 0), 1)
return CGSize(width: base.width + (compact ? 3 : 8) * level,
height: base.height + (compact ? 6 : 16) * level)
case .speaking(let a):
let level = min(max(a, 0), 1)
return CGSize(width: base.width + (compact ? 4.5 : 12) * level,
height: base.height + (compact ? 9 : 24) * level)
case .waiting, .thinking:
// Pulse drives opacity; height steps up one band.
return CGSize(width: base.width, height: base.height + (compact ? 3 : 8))
case .connecting, .idle, .failed, .tearingDown, .readyNoInput:
return base
}
}
private var overallOpacity: Double {
switch phase {
case .connecting, .waiting:
return 0.45 + 0.45 * pulse
case .thinking:
return 0.65 + 0.35 * pulse
case .idle, .failed, .tearingDown:
return 0.45
case .listening, .speaking, .readyNoInput:
return 1
}
}
}
@@ -1,27 +1,14 @@
import SwiftUI
struct OrbView: View {
enum OrbState { case idle, connecting, listening, speaking, thinking, failed }
let state: OrbState
var body: some View {
ZStack {
Circle().fill(HopeStyle.tint.opacity(0.10))
Image(systemName: state == .failed ? "wifi.exclamationmark" : "waveform")
.font(.largeTitle.weight(.medium))
.foregroundStyle(HopeStyle.tint)
}
.frame(width: 76, height: 76)
.accessibilityLabel("Hope")
}
}
/// 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
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var typeSize
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*"#
@@ -29,7 +16,9 @@ struct TranscriptListView: View {
ScrollViewReader { proxy in
VStack(spacing: 0) {
HStack {
Text("Conversation").font(.subheadline.weight(.semibold))
Text("Conversation")
.font(NearlightFont.support(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.quiet)
Spacer()
Menu {
Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true }
@@ -39,20 +28,23 @@ struct TranscriptListView: View {
.accessibilityLabel("Conversation options")
.disabled(events.isEmpty)
}
.padding(.horizontal, 20)
.padding(.horizontal, Nearlight.s20)
ScrollView {
LazyVStack(alignment: .leading, spacing: 20) {
LazyVStack(alignment: .leading, spacing: Nearlight.s24) {
if events.isEmpty {
Text("Your conversation will appear here.")
.font(.body).foregroundStyle(.secondary)
.padding(.vertical, 32)
.font(.body)
.foregroundStyle(Nearlight.quiet)
.padding(.top, Nearlight.s24)
}
ForEach(events) { event in transcriptRow(event) }
Color.clear.frame(height: 1).id("latest")
.onAppear { followingLatest = true }
.onDisappear { followingLatest = false }
}
.padding(.horizontal, 20).padding(.bottom, 20)
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s16)
.padding(.bottom, Nearlight.s20)
}
.scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.bottom)
@@ -64,13 +56,15 @@ struct TranscriptListView: View {
}
if !followingLatest && !events.isEmpty {
Button {
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) {
withAnimation(reduceMotion ? nil : Nearlight.standard) {
proxy.scrollTo("latest", anchor: .bottom)
}
} label: {
Label("Latest messages", systemImage: "arrow.down").font(.subheadline)
Label("Latest messages", systemImage: "arrow.down")
.font(NearlightFont.support(typeSize))
.frame(minHeight: 44)
}
.accessibilityIdentifier("talk.latest")
}
}
}
@@ -86,28 +80,52 @@ struct TranscriptListView: View {
let isUser = event.role == .user
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespaces)
return HStack(alignment: .top, spacing: 0) {
if isUser { Spacer(minLength: 32) }
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
Text(isUser ? "You" : "Hope").font(.subheadline.weight(.semibold))
Text(event.timestamp, style: .time).font(.caption).foregroundStyle(.secondary)
if !event.isFinal {
Text("Live").font(.caption).foregroundStyle(.secondary)
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).textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(isUser ? 14 : 0)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isUser ? Color(.secondarySystemBackground) : .clear, in: RoundedRectangle(cornerRadius: 16))
.contextMenu {
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
}
if !isUser { Spacer(minLength: 16) }
Text(text)
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contextMenu {
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
}
.accessibilityElement(children: .combine)
.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()
}
}
+22 -4
View File
@@ -12,6 +12,7 @@ struct SettingsView: View {
@State private var urlText: String = ""
@State private var urlError: String?
@State private var showVoicePicker = false
@State private var urlJustSaved = false
@State private var showModelPicker = false
init(settings: SettingsStore, session: VoiceSession) {
@@ -55,7 +56,7 @@ struct SettingsView: View {
} else {
LabeledContent("Model", value: settings.modelID ?? "Server default")
Text("Connect from Talk to see the models available on your server.")
.font(.footnote).foregroundStyle(.secondary)
.font(.footnote).foregroundStyle(Nearlight.quiet)
}
} header: { Text("Intelligence") }
Section {
@@ -65,10 +66,24 @@ struct SettingsView: View {
.textInputAutocapitalization(.never)
.accessibilityLabel("Server URL")
.onAppear { urlText = settings.serverURL }
.overlay(alignment: .trailing) {
if urlJustSaved {
Image(systemName: "checkmark.circle")
.foregroundStyle(Nearlight.quiet)
.padding(.trailing, 8)
.accessibilityHidden(true)
}
}
if let urlError {
Text(urlError)
Label(urlError, systemImage: "exclamationmark.circle")
.font(.footnote)
.foregroundStyle(.red)
.foregroundStyle(Nearlight.alert)
.accessibilityIdentifier("settings.server.error")
} else if urlJustSaved {
Text("Server address saved.")
.font(.footnote)
.foregroundStyle(Nearlight.quiet)
.accessibilityIdentifier("settings.server.saved")
}
Button("Save server address") { applyServerURL() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
@@ -77,7 +92,7 @@ struct SettingsView: View {
? "Waiting for failure cleanup to finish…"
: "End the conversation before changing servers.")
.font(.footnote)
.foregroundStyle(.secondary)
.foregroundStyle(Nearlight.quiet)
}
NavigationLink("Connection test") {
ConnectionTestView(settings: settings)
@@ -90,6 +105,7 @@ struct SettingsView: View {
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.tint(Nearlight.quiet)
.sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session)
}
@@ -103,6 +119,7 @@ struct SettingsView: View {
/// left untouched so the active session keeps using its original URL.
/// Guarded: only applies when the session is idle (no active call).
private func applyServerURL() {
urlJustSaved = false
guard canApplyServerURL else {
urlError = "End the conversation before changing servers."
return
@@ -117,6 +134,7 @@ struct SettingsView: View {
settings.serverURL = trimmed
urlText = trimmed
urlError = nil
urlJustSaved = true
case .failure(let error):
urlError = error.localizedDescription
}
+191 -92
View File
@@ -1,141 +1,240 @@
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 {
ScrollView {
LazyVStack(alignment: .leading, spacing: 20) {
if taskStore.sortedTasks.isEmpty {
emptyState
} else {
Text(taskStore.activeCount > 0 ? "Hope is working on \(taskStore.activeCount) task\(taskStore.activeCount == 1 ? "" : "s"). You can keep talking." : "Results from the things you’ve asked Hope to work on.")
.font(.subheadline).foregroundStyle(.secondary)
ForEach(taskStore.sortedTasks) { task in
TaskCardView(task: task)
}
}
Group {
if taskStore.sortedTasks.isEmpty {
emptyState
} else {
taskList
}
.padding()
}
.navigationTitle("Activity")
.background(Color(.systemGroupedBackground))
.background(Nearlight.ground)
}
private var taskList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(taskStore.sortedTasks) { task in
NavigationLink {
TaskDetailView(task: task)
} label: {
TaskRowView(task: task)
}
.buttonStyle(.plain)
Divider()
.overlay(Nearlight.separator)
}
}
.padding(.horizontal, Nearlight.s20)
}
}
private var emptyState: some View {
ContentUnavailableView {
Label("A little help, in the background", systemImage: "checklist")
} description: {
Text("Ask Hope to research a topic or compare your options. Follow the progress and read the results here.")
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, 48)
.padding(.top, Nearlight.s32)
.padding(.horizontal, Nearlight.s20)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("activity.empty")
}
}
struct TaskCardView: View {
/// One activity row: status symbol, title, current step/status, chevron.
private struct TaskRowView: View {
let task: TaskItem
@State private var showSteps = false
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Label(statusTitle, systemImage: statusSymbol)
.font(.subheadline.weight(.medium))
.foregroundStyle(dotColor)
Spacer()
Text(task.createdAt, style: .time).font(.caption).foregroundStyle(.secondary)
}
Text(task.description).font(.headline).fixedSize(horizontal: false, vertical: true)
if !task.steps.isEmpty {
DisclosureGroup("\(task.steps.count) progress updates", isExpanded: $showSteps) {
VStack(alignment: .leading, spacing: 12) {
ForEach(task.steps) { step in
VStack(alignment: .leading, spacing: 4) {
Text(stepLabel(step.role))
.font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Text(step.content ?? "")
.font(.subheadline).textSelection(.enabled)
}
}
}.padding(.top, 8)
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)
}
.font(.subheadline)
}
if let result = task.result {
block(result, color: .green, background: Color.green.opacity(0.12))
}
if let error = task.error {
block(error, color: .red, background: Color.red.opacity(0.12))
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(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16))
.padding(.vertical, Nearlight.s16)
.contentShape(Rectangle())
}
private var statusTitle: String {
switch task.status {
case .pending: "Queued"
case .running: "In progress"
case .completed: "Complete"
case .failed: "Needs attention"
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.
struct TaskDetailView: 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)
}
.background(Nearlight.ground)
.navigationTitle("Task")
.navigationBarTitleDisplayMode(.inline)
}
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: "clock"
case .running: "arrow.triangle.2.circlepath"
case .completed: "checkmark.circle.fill"
case .failed: "exclamationmark.circle"
case .pending: return "circle.dotted"
case .running: return "arrow.triangle.2.circlepath"
case .completed: return "checkmark.circle"
case .failed: return "exclamationmark.circle"
}
}
private var dotColor: Color {
private var statusColor: Color {
switch task.status {
case .pending: return .gray
case .running: return HopeStyle.tint
case .completed: return .green
case .failed: return .red
case .pending: return Nearlight.quiet
case .running: return Nearlight.rosewood
case .completed: return Nearlight.quiet
case .failed: return Nearlight.alert
}
}
private func block(_ text: String, color: Color, background: Color) -> some View {
Text(text)
.font(.body)
.foregroundStyle(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func stepLabel(_ role: String?) -> String {
private func roleLabel(_ role: String) -> String {
switch role {
case "thinking": return "Thinking"
case "tool_call": return "Tool"
case "tool_result": return "Result"
case "text": return "Note"
default: return role ?? "Step"
}
}
private func stepColor(_ role: String?) -> Color {
switch role {
case "thinking": return .blue
case "tool_call": return .indigo
case "tool_result": return .secondary
default: return .primary
default: return role
}
}
}
#Preview {
NavigationStack {
TaskPanelView(taskStore: {
let store = TaskStore()
#if DEBUG
store.injectSampleEvents()
#endif
return store
}())
TaskPanelView(taskStore: TaskStore())
}
}
+10 -10
View File
@@ -13,15 +13,15 @@ final class VoiceUITests: XCTestCase {
func testWelcomeNavigationAndOfflineRecovery() {
app.launch()
XCTAssertTrue(app.buttons["Let’s talk"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["Your microphone turns on when you connect."].exists)
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["Your microphone starts after you connect."].exists)
app.buttons["Type"].tap()
XCTAssertTrue(app.buttons["Start chatting"].exists)
XCTAssertTrue(app.staticTexts["Your microphone stays off."].exists)
app.buttons["Start chatting"].tap()
XCTAssertTrue(app.buttons["Try again"].waitForExistence(timeout: 20))
XCTAssertTrue(app.staticTexts["Couldn’t connect to Hope"].exists)
app.buttons["Check connection settings"].tap()
XCTAssertTrue(app.staticTexts["Couldn’t connect"].exists)
app.buttons["Settings"].tap()
XCTAssertTrue(app.textFields["Server URL"].waitForExistence(timeout: 5))
capture("Offline recovery")
}
@@ -59,23 +59,23 @@ final class VoiceUITests: XCTestCase {
app.buttons["Conversation options"].tap()
app.buttons["Clear conversation"].tap()
app.buttons["Clear conversation"].tap()
XCTAssertTrue(app.buttons["Let’s talk"].exists)
XCTAssertTrue(app.buttons["Start chatting"].exists)
XCTAssertFalse(app.staticTexts["Help me plan a quiet weekend."].exists)
app.tabBars.buttons["Activity"].tap()
XCTAssertTrue(app.staticTexts["Needs attention"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["Search backend timed out after 30s"].exists)
app.buttons["1 progress updates"].tap()
XCTAssertTrue(app.staticTexts["Fetching benchmark articles…"].exists)
app.staticTexts["Search backend timed out after 30s"].tap()
XCTAssertTrue(app.staticTexts["Fetching benchmark articles…"].waitForExistence(timeout: 5))
capture("Activity details")
}
func testLargeTypeNavigation() {
app.launchArguments += ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
app.launch()
XCTAssertTrue(app.buttons["Let’s talk"].waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Let’s talk"].isHittable)
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Start chatting"].isHittable)
app.tabBars.buttons["Activity"].tap()
XCTAssertTrue(app.staticTexts["A little help, in the background"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 5))
app.buttons["Settings"].tap()
XCTAssertTrue(app.navigationBars["Settings"].exists)
capture("Accessibility text size")
+15 -7
View File
@@ -12,7 +12,6 @@
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; };
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */; };
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; };
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
@@ -20,14 +19,17 @@
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; };
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; };
943BCE971A01F220FAE9E250 /* TaskPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85BACA267ADD462306C9920 /* TaskPanelView.swift */; };
9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */; };
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
A5868238CF4958D6D0C5908E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */; };
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */; };
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */; };
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */; };
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */; };
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; };
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */; };
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6FA2A93116B237CEA712E6 /* Nearlight.swift */; };
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; };
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */; };
@@ -64,13 +66,15 @@
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; };
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; };
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = "<group>"; };
A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrbView.swift; sourceTree = "<group>"; };
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptListView.swift; sourceTree = "<group>"; };
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStateTests.swift; sourceTree = "<group>"; };
C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = "<group>"; };
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = "<group>"; };
@@ -147,7 +151,8 @@
isa = PBXGroup;
children = (
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */,
A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */,
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */,
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */,
);
path = Conversation;
sourceTree = "<group>";
@@ -175,6 +180,7 @@
CE49A38E40AAF659A9281B85 /* App */ = {
isa = PBXGroup;
children = (
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */,
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */,
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */,
2E5DF207EDB787F63CB3A64D /* Voices.swift */,
@@ -282,7 +288,7 @@
TestTargetID = 57E3A292BDE525A22EB7ADE4;
};
57E3A292BDE525A22EB7ADE4 = {
DevelopmentTeam = H8D4WY66F6;
DevelopmentTeam = YJFYRB92NW;
ProvisioningStyle = Automatic;
};
};
@@ -321,7 +327,8 @@
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */,
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */,
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */,
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */,
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */,
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */,
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */,
@@ -331,6 +338,7 @@
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */,
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */,
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */,
9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */,
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */,
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */,
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */,
@@ -498,7 +506,7 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H8D4WY66F6;
DEVELOPMENT_TEAM = YJFYRB92NW;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -521,7 +529,7 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H8D4WY66F6;
DEVELOPMENT_TEAM = YJFYRB92NW;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
+1 -1
View File
@@ -41,7 +41,7 @@ targets:
TARGETED_DEVICE_FAMILY: "1"
CURRENT_PROJECT_VERSION: 1
MARKETING_VERSION: "0.1.0"
DEVELOPMENT_TEAM: H8D4WY66F6
DEVELOPMENT_TEAM: YJFYRB92NW
CODE_SIGN_STYLE: Automatic
VoiceTests:
type: bundle.unit-test