feat: conversation state machine, mic meter, history, wake lock (Phase 3)

This commit is contained in:
snowspeeder committed 2026-08-30 07:36:13 -04:00
1 parent 4986b6d7dc
commit 5483e6ed48
8 files changed
+348 -29

No files matched your search

@@ -31,7 +31,11 @@ struct ConversationView: View {
NavigationStack {
VStack(spacing: 0) {
statusHeader
TranscriptListView(events: session.transcriptHandler.events)
TranscriptListView(
events: session.transcriptHandler.events,
history: session.transcriptHandler.history,
onClear: { session.transcriptHandler.clearHistory() }
)
composer
}
.navigationTitle("Hope Voice")
@@ -49,6 +53,21 @@ struct ConversationView: View {
OrbView(state: orbState)
Text(statusText)
.font(.headline)
if session.state == .ready, session.stateModel.isThinkingChipVisible {
Label("Thinking…", systemImage: "ellipsis")
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.orange.opacity(0.2), in: Capsule())
}
if session.state == .ready, !settings.textMode {
micMeter
}
if session.state == .ready, settings.textMode {
Text("Barge in anytime — type to reply")
.font(.caption)
.foregroundStyle(.secondary)
}
if let agent = session.agent {
Text("Agent: \(agent.name ?? agent.identity)")
.font(.caption)
@@ -63,10 +82,16 @@ struct ConversationView: View {
Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic")
}
.toggleStyle(.button)
Toggle("Text mode", isOn: $settings.textMode)
Toggle("Text mode", isOn: textModeBinding)
.toggleStyle(.button)
}
}
if let micError = session.micError {
Label(micError, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let connectError {
Label(connectError, systemImage: "exclamationmark.triangle")
.font(.footnote)
@@ -76,6 +101,40 @@ struct ConversationView: View {
.padding()
}
private var micMeter: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color(.systemFill))
Capsule()
.fill(Color.green.gradient)
.frame(width: min(geo.size.width, geo.size.width * session.stateModel.micLevel * 2.5))
}
}
.frame(height: 4)
}
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()
}
}
private var composer: some View {
HStack(spacing: 8) {
switch session.state {
@@ -118,20 +177,35 @@ struct ConversationView: View {
private var orbState: OrbView.OrbState {
switch session.state {
case .idle: .idle
case .connecting, .waitingAgent: .connecting
case .ready: settings.textMode ? .idle : .listening
case .failed: .failed
case .idle: return .idle
case .connecting, .waitingAgent: return .connecting
case .ready:
guard !settings.textMode else { return .idle }
switch session.stateModel.phase {
case .idle: return .idle
case .connecting: return .connecting
case .listening: return .listening
case .thinking: return .thinking
case .speaking: return .speaking
}
case .failed: return .failed
}
}
private var statusText: String {
switch session.state {
case .idle: "Ready to connect"
case .connecting: "Connecting…"
case .waitingAgent: "Waiting for agent…"
case .ready: settings.textMode ? "Text mode" : "Listening"
case .failed(let message): "Failed: \(message)"
case .idle: return "Ready to connect"
case .connecting: return "Connecting…"
case .waitingAgent: return "Waiting for agent…"
case .ready:
if settings.textMode { return "Text mode" }
switch session.stateModel.phase {
case .listening: return "Listening"
case .thinking: return "Thinking it through"
case .speaking: return "Hope is speaking"
default: return "Ready when you are"
}
case .failed(let message): return "Failed: \(message)"
}
}