This commit is contained in:
snowspeeder committed 2026-08-31 19:50:46 -04:00
1 parent c9c25f86eb
commit 40aaf4fea7
56 files changed
+5683 -179

No files matched your search

@@ -5,25 +5,47 @@ struct ConversationView: View {
@ObservedObject var settings: SettingsStore
@State private var sessionID = UUID()
@State private var session: VoiceSession
/// The exact server URL the current session was constructed with. Used to
/// avoid redundant session replacement when the applied URL hasn't actually
/// changed (e.g. re-applying the same value from Settings).
@State private var activeServerURL: String
/// Identity guard: prevents a second `.onChange` from starting a concurrent
/// session replacement while the first is still disconnecting the old one.
@State private var isReplacingSession = false
/// Latest accepted server URL that has not yet been applied to a session.
/// Coalesced: rapid Applies during an active replacement retain the newest
/// value rather than dropping it. A single retained worker loop drains
/// arbitrary pending URLs until none remain (Iteration 08 Item 2).
@State private var pendingServerURL: String?
@State private var composerText = ""
@State private var connectError: String?
@State private var tick = 0
init(settings: SettingsStore) {
self.settings = settings
_session = State(initialValue: Self.makeSession(settings: settings))
let initialSession = Self.makeSession(settings: settings)
_session = State(initialValue: initialSession)
_activeServerURL = State(initialValue: settings.trimmedServerURL)
}
/// Creates a session from the configured server URL. `SettingsStore.init`
/// guarantees the stored value is always valid (legacy invalid values are
/// sanitized to the documented default), so the failure branch is a safety
/// net only.
private static func makeSession(settings: SettingsStore) -> VoiceSession {
let config = ServerConfig.parse(settings.trimmedServerURL)
if case .success(let value) = config {
switch config {
case .success(let value):
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
case .failure:
// Defensive: should be unreachable because SettingsStore sanitizes.
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
if case .success(let value) = fallback {
settings.serverURL = SettingsStore.defaultServerURL
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
}
fatalError("Unreachable: default server URL is valid")
}
let fallback = ServerConfig.parse("https://192.168.86.2:9178")
if case .success(let value) = fallback {
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
}
fatalError("Unreachable: default server URL is valid")
}
var body: some View {
@@ -32,8 +54,11 @@ struct ConversationView: View {
statusHeader
TranscriptListView(
events: session.transcriptHandler.events,
history: session.transcriptHandler.history,
onClear: { session.transcriptHandler.clearHistory() }
onClear: {
// Atomically clear the canonical visible collection,
// dedupe state, and persistence so Clear is truthful.
session.transcriptHandler.clearAll()
}
)
composer
}
@@ -45,17 +70,56 @@ struct ConversationView: View {
connectError = error.localizedDescription
}
}
.onChange(of: settings.textMode) { _, newValue in
guard session.isReady else { return }
Task { await session.applyTextMode(newValue) }
}
}
.onReceive(session.objectWillChange.receive(on: RunLoop.main)) { _ in
tick &+= 1
}
.onChange(of: settings.serverURL) { _, _ in
guard session.state == .idle else { return }
let oldSession = session
.onChange(of: settings.serverURL) { _, newValue in
// The URL field now uses explicit Apply semantics (SettingsView),
// so this fires only when the user has applied a value that passed
// validation. Coalesce rapid accepted values: every accepted Apply
// writes `pendingServerURL` (latest wins). A single retained worker
// loop drains arbitrary pending URLs until none remain, covering
// A→B→C semantics where C arrives while B is disconnecting
// (Iteration 08 Item 2).
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, case .success = ServerConfig.parse(trimmed) else { return }
pendingServerURL = trimmed
// Start the worker if not already active. The loop consumes
// pending values until empty, so a single Task handles any number
// of rapid Applies without nested tasks.
guard !isReplacingSession else { return }
isReplacingSession = true
Task { @MainActor [settings] in
await oldSession.disconnect()
self.sessionID = UUID()
self.session = Self.makeSession(settings: settings)
while let url = self.pendingServerURL {
self.pendingServerURL = nil
guard url != self.activeServerURL else { continue }
let oldSession = self.session
await oldSession.disconnect()
self.activeServerURL = url
self.sessionID = UUID()
// Construct from the captured validated config, not by
// rereading settings.serverURL (which may have changed
// during disconnect).
switch ServerConfig.parse(url) {
case .success(let config):
self.session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
case .failure:
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
if case .success(let value) = fallback {
self.session = VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
} else {
fatalError("Unreachable: default server URL is valid")
}
}
}
self.isReplacingSession = false
}
}
}
@@ -92,6 +156,8 @@ struct ConversationView: View {
}
if session.state == .ready {
HStack(spacing: 16) {
// The mic toggle is disabled in text mode to enforce the
// privacy invariant at the UI level as well (Finding 4).
Toggle(isOn: Binding(
get: { !session.isMuted },
set: { newValue in Task { await session.setMuted(!newValue) } }
@@ -99,6 +165,7 @@ struct ConversationView: View {
Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic")
}
.toggleStyle(.button)
.disabled(settings.textMode)
Toggle("Text mode", isOn: textModeBinding)
.toggleStyle(.button)
}
@@ -138,24 +205,7 @@ struct ConversationView: View {
}
private var textModeBinding: Binding<Bool> {
Binding(
get: { settings.textMode },
set: { newValue in
settings.textMode = newValue
Task { await applyTextMode(newValue) }
}
)
}
private func applyTextMode(_ enabled: Bool) async {
guard session.isReady else { return }
if enabled {
await session.setMuted(true)
await session.setMode("text")
} else {
await session.setMode("voice")
await session.enableMicrophone()
}
$settings.textMode
}
private var composer: some View {
@@ -166,7 +216,7 @@ struct ConversationView: View {
Label("Start", systemImage: "phone.fill")
}
.buttonStyle(.borderedProminent)
case .connecting, .waitingAgent:
case .connecting, .waitingAgent, .tearingDown:
ProgressView()
case .ready:
TextField(settings.textMode ? "Type a message…" : "Voice mode active",
@@ -231,7 +281,7 @@ struct ConversationView: View {
private var orbState: OrbView.OrbState {
switch session.state {
case .idle: return .idle
case .connecting, .waitingAgent: return .connecting
case .connecting, .waitingAgent, .tearingDown: return .connecting
case .ready:
guard !settings.textMode else { return .idle }
switch session.stateModel.phase {
@@ -250,6 +300,7 @@ struct ConversationView: View {
case .idle: return "Ready to connect"
case .connecting: return "Connecting…"
case .waitingAgent: return "Waiting for agent…"
case .tearingDown: return "Cleaning up…"
case .ready:
if settings.textMode { return "Text mode" }
switch session.stateModel.phase {
@@ -264,7 +315,8 @@ struct ConversationView: View {
private func startSession() {
connectError = nil
session.transcriptHandler.clear()
// Starting a conversation preserves the restored last-50 history; only
// the explicit Clear button erases it.
Task { await session.connect() }
}
+8 -4
View File
@@ -62,8 +62,10 @@ struct OrbView: View {
}
struct TranscriptListView: View {
/// The single canonical visible transcript (restored persisted finals plus
/// current live events). The view renders exactly this collection — there is
/// no second "restored" store, so the UI and persistence can never diverge.
let events: [TranscriptEvent]
@ObservedObject var history: TranscriptHistory
var onClear: () -> Void
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*"#
@@ -75,6 +77,8 @@ struct TranscriptListView: View {
return formatter
}()
private var visibleRows: [TranscriptEvent] { events }
var body: some View {
ScrollViewReader { proxy in
VStack(alignment: .leading, spacing: 0) {
@@ -93,14 +97,14 @@ struct TranscriptListView: View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(events) { event in
ForEach(visibleRows) { event in
transcriptRow(event)
}
}
.padding(.horizontal)
}
.onChange(of: events.count) { _, count in
if let last = events.last {
.onChange(of: visibleRows.count) { _, _ in
if let last = visibleRows.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}