Skip to content

Commit e3aaa49

Browse files
renal128cursoragent
andcommitted
feat: widget conversation modes
Add WidgetConversationMode so a host can run the widget as voice-only, text-only, or both. Text-only conversations connect with audio disabled and start implicitly on the first message; voice-only drops the composer and keeps the orb above the transcript. The demo switches modes at runtime. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 97750a6 commit e3aaa49

7 files changed

Lines changed: 97 additions & 15 deletions

File tree

Examples/DemoApp/DemoApp/ContentView.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ struct ContentView: View {
88
private static let agentId = ""
99

1010
@StateObject private var chat = ChatWidgetController()
11+
@State private var conversationMode: WidgetConversationMode = .voiceAndText
1112

1213
private var hasAgentId: Bool {
1314
!Self.agentId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@@ -50,6 +51,7 @@ struct ContentView: View {
5051
if hasAgentId {
5152
ChatWidget(
5253
authProvider: { .publicAgent(id: Self.agentId) },
54+
widgetConfig: ChatWidgetConfig(conversationMode: conversationMode),
5355
controller: chat
5456
)
5557
}
@@ -62,6 +64,13 @@ struct ContentView: View {
6264
.font(.caption)
6365
.foregroundStyle(.secondary)
6466

67+
Picker("Mode", selection: $conversationMode) {
68+
Text("Voice + text").tag(WidgetConversationMode.voiceAndText)
69+
Text("Voice only").tag(WidgetConversationMode.voiceOnly)
70+
Text("Text only").tag(WidgetConversationMode.textOnly)
71+
}
72+
.pickerStyle(.segmented)
73+
6574
HStack(spacing: 12) {
6675
Button("Open chat") { chat.open() }
6776
Button("Close chat") { chat.close() }

Sources/ElevenLabsWidget/Configuration/ChatWidgetConfig.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import Foundation
33

44
/// Behavior and appearance of ``ChatWidget``.
55
public struct ChatWidgetConfig: Equatable, Sendable {
6+
/// Whether the user talks, types, or both.
7+
public var conversationMode: WidgetConversationMode
68
/// Dim the host UI behind the open drawer.
79
public var showBackdrop: Bool
810
/// Show the microphone mute button while a conversation is live.
@@ -11,11 +13,13 @@ public struct ChatWidgetConfig: Equatable, Sendable {
1113
public var theme: ChatWidgetTheme
1214

1315
public init(
16+
conversationMode: WidgetConversationMode = .voiceAndText,
1417
showBackdrop: Bool = true,
1518
enableMicMuteControl: Bool = true,
1619
strings: ChatWidgetStrings = .default,
1720
theme: ChatWidgetTheme = .default
1821
) {
22+
self.conversationMode = conversationMode
1923
self.showBackdrop = showBackdrop
2024
self.enableMicMuteControl = enableMicMuteControl
2125
self.strings = strings
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#if canImport(UIKit)
2+
import Foundation
3+
4+
/// Which ways the user can talk to the agent through ``ChatWidget``.
5+
public enum WidgetConversationMode: String, CaseIterable, Identifiable, Sendable {
6+
/// Typed messages only; the conversation runs without audio.
7+
case textOnly
8+
/// A voice call with no composer.
9+
case voiceOnly
10+
/// A voice call the user can also type into.
11+
case voiceAndText
12+
13+
public var id: String {
14+
rawValue
15+
}
16+
17+
public var supportsVoice: Bool {
18+
self != .textOnly
19+
}
20+
21+
public var supportsTextInput: Bool {
22+
self != .voiceOnly
23+
}
24+
}
25+
#endif

Sources/ElevenLabsWidget/ViewModels/ChatWidgetViewModel.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ final class ChatWidgetViewModel: ObservableObject {
1616
@Published private(set) var isMicMuted = false
1717
@Published private(set) var isSending = false
1818

19-
let widgetConfig: ChatWidgetConfig
19+
/// Mirrors the config the host passes to ``ChatWidget``, so changes take
20+
/// effect without recreating the conversation.
21+
@Published var widgetConfig: ChatWidgetConfig
2022
let client: ConversationClient
2123
let audioLevels = OrbAudioLevels()
2224

@@ -79,7 +81,27 @@ final class ChatWidgetViewModel: ObservableObject {
7981
}
8082

8183
var canToggleMicMute: Bool {
82-
widgetConfig.enableMicMuteControl && conversationState.isConnected
84+
widgetConfig.enableMicMuteControl && mode.supportsVoice && conversationState.isConnected
85+
}
86+
87+
var canShowTextInput: Bool {
88+
mode.supportsTextInput
89+
}
90+
91+
var canStartVoiceConversation: Bool {
92+
mode.supportsVoice && !hasActiveConversation
93+
}
94+
95+
private var mode: WidgetConversationMode {
96+
widgetConfig.conversationMode
97+
}
98+
99+
/// Text-only widgets run the conversation without audio.
100+
private var effectiveConversationConfig: ConversationConfig {
101+
guard !mode.supportsVoice else { return conversationConfig }
102+
var config = conversationConfig
103+
config.conversationOverrides.textOnly = true
104+
return config
83105
}
84106

85107
var orbState: ChatOrbState {
@@ -133,7 +155,7 @@ final class ChatWidgetViewModel: ObservableObject {
133155
if let startTask { return try await startTask.value }
134156
guard !hasActiveConversation else { return }
135157
let task = Task {
136-
_ = try await client.startConversation(auth: authProvider(), config: conversationConfig)
158+
_ = try await client.startConversation(auth: authProvider(), config: effectiveConversationConfig)
137159
}
138160
startTask = task
139161
defer { startTask = nil }

Sources/ElevenLabsWidget/Views/ChatInputBar.swift

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,34 @@ struct ChatInputBar: View {
1717

1818
var body: some View {
1919
VStack(spacing: 10) {
20-
TextField(strings.inputPlaceholder, text: $vm.input, axis: .vertical)
21-
.textFieldStyle(.plain)
22-
.lineLimit(1 ... 3)
23-
.padding(.horizontal, 8)
24-
.frame(minHeight: 36)
25-
.focused(isInputFocused)
26-
.onSubmit(vm.send)
20+
if vm.canShowTextInput {
21+
TextField(strings.inputPlaceholder, text: $vm.input, axis: .vertical)
22+
.textFieldStyle(.plain)
23+
.lineLimit(1 ... 3)
24+
.padding(.horizontal, 8)
25+
.frame(minHeight: 36)
26+
.focused(isInputFocused)
27+
.onSubmit(vm.send)
28+
}
2729

2830
HStack(spacing: 10) {
2931
if vm.canToggleMicMute {
3032
ChatMicButton(vm: vm, levels: vm.audioLevels, diameter: 38, theme: theme)
3133
}
34+
// Without a composer there is nothing to push the call controls
35+
// away from, so they sit centered instead.
3236
Spacer(minLength: 0)
3337
if vm.hasActiveConversation {
3438
endConversationButton
35-
} else {
39+
} else if vm.canStartVoiceConversation {
3640
startConversationButton
3741
}
38-
sendButton
42+
if vm.canShowTextInput {
43+
sendButton
44+
}
45+
if !vm.canShowTextInput {
46+
Spacer(minLength: 0)
47+
}
3948
}
4049
}
4150
.padding(.horizontal, 14)
@@ -50,7 +59,7 @@ struct ChatInputBar: View {
5059
.strokeBorder(theme.border, lineWidth: 1)
5160
)
5261
.contentShape(Rectangle())
53-
.onTapGesture { isInputFocused.wrappedValue = true }
62+
.onTapGesture { if vm.canShowTextInput { isInputFocused.wrappedValue = true } }
5463
}
5564

5665
private var startConversationButton: some View {

Sources/ElevenLabsWidget/Views/ChatPopupView.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ struct ChatPopupView: View {
1919
Divider()
2020
if vm.messages.isEmpty {
2121
Spacer(minLength: 0)
22-
ChatOrbView(levels: vm.audioLevels, state: vm.orbState, size: 128, theme: vm.widgetConfig.theme)
22+
orb(size: 128)
2323
Spacer(minLength: 0)
2424
} else {
25+
// A voice-only drawer has no composer to anchor it, so the orb
26+
// stays visible above the transcript.
27+
if !vm.canShowTextInput {
28+
orb(size: 96).padding(.vertical, 12)
29+
}
2530
ChatTranscriptView(vm: vm)
2631
}
2732
ChatInputBar(vm: vm, isInputFocused: $isInputFocused)
@@ -39,9 +44,13 @@ struct ChatPopupView: View {
3944
.padding(.bottom, 8)
4045
}
4146

47+
private func orb(size: CGFloat) -> some View {
48+
ChatOrbView(levels: vm.audioLevels, state: vm.orbState, size: size, theme: vm.widgetConfig.theme)
49+
}
50+
4251
private var header: some View {
4352
HStack(spacing: 12) {
44-
ChatOrbView(levels: vm.audioLevels, state: vm.orbState, size: 36, theme: vm.widgetConfig.theme)
53+
orb(size: 36)
4554
Text(strings.title)
4655
.font(.headline)
4756
Spacer(minLength: 0)

Sources/ElevenLabsWidget/Views/ChatWidget.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import SwiftUI
1414
public struct ChatWidget: View {
1515
@StateObject private var vm: ChatWidgetViewModel
1616
private let controller: ChatWidgetController?
17+
private let widgetConfig: ChatWidgetConfig
1718
private let launcher: (() -> AnyView)?
1819

1920
/// - Parameters:
@@ -31,6 +32,7 @@ public struct ChatWidget: View {
3132
onClientToolCall: (@MainActor (ClientToolCallEvent) async -> ClientToolResultEvent)? = nil
3233
) {
3334
self.controller = controller
35+
self.widgetConfig = widgetConfig
3436
self.launcher = launcher
3537
// Built inside the autoclosure so SwiftUI only creates it once, rather
3638
// than on every re-init of this view.
@@ -69,6 +71,8 @@ public struct ChatWidget: View {
6971
// view update — otherwise the host's controller invalidates the view that
7072
// is still being built.
7173
.task { if let controller { vm.attach(to: controller) } }
74+
// The view model outlives every re-init, so config changes are pushed to it.
75+
.onChange(of: widgetConfig) { vm.widgetConfig = $0 }
7276
}
7377

7478
@ViewBuilder

0 commit comments

Comments
 (0)