Skip to content

Commit 9a3c8b5

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 86f9a4a commit 9a3c8b5

6 files changed

Lines changed: 91 additions & 14 deletions

File tree

Examples/DemoApp/DemoApp/ContentView.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ struct ContentView: View {
1818
/// Shared by the button and the client tool, so a tool call arriving during a
1919
/// search awaits that one instead of starting a second, overlapping search.
2020
@State private var inFlightSearch: Task<Int, Never>?
21+
@State private var conversationMode: WidgetConversationMode = .voiceAndText
2122

2223
var body: some View {
2324
ZStack {
@@ -33,6 +34,7 @@ struct ContentView: View {
3334

3435
ChatWidget(
3536
authProvider: { .publicAgent(id: Self.agentId) },
37+
widgetConfig: ChatWidgetConfig(conversationMode: conversationMode),
3638
conversationConfig: ConversationConfig(
3739
agentOverrides: AgentOverrides(
3840
prompt: """
@@ -110,6 +112,14 @@ struct ContentView: View {
110112
.disabled(!chat.state.isConnected)
111113
}
112114
.buttonStyle(.bordered)
115+
116+
Picker("Mode", selection: $conversationMode) {
117+
Text("Voice & text").tag(WidgetConversationMode.voiceAndText)
118+
Text("Voice").tag(WidgetConversationMode.voiceOnly)
119+
Text("Text").tag(WidgetConversationMode.textOnly)
120+
}
121+
.pickerStyle(.segmented)
122+
.frame(maxWidth: 300)
113123
}
114124
}
115125

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: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,27 @@ final class ChatWidgetViewModel: ObservableObject {
6868
}
6969

7070
var canToggleMicMute: Bool {
71-
widgetConfig.enableMicMuteControl && conversationState.isConnected
71+
widgetConfig.enableMicMuteControl && mode.supportsVoice && conversationState.isConnected
72+
}
73+
74+
var canShowTextInput: Bool {
75+
mode.supportsTextInput
76+
}
77+
78+
var canStartVoiceConversation: Bool {
79+
mode.supportsVoice && !hasActiveConversation
80+
}
81+
82+
private var mode: WidgetConversationMode {
83+
widgetConfig.conversationMode
84+
}
85+
86+
/// Text-only widgets run the conversation without audio.
87+
private var effectiveConversationConfig: ConversationConfig {
88+
guard !mode.supportsVoice else { return conversationConfig }
89+
var config = conversationConfig
90+
config.conversationOverrides.textOnly = true
91+
return config
7292
}
7393

7494
var orbState: ChatOrbState {
@@ -106,7 +126,7 @@ final class ChatWidgetViewModel: ObservableObject {
106126

107127
func startConversationAndWait() async throws {
108128
guard !hasActiveConversation else { return }
109-
_ = try await client.startConversation(auth: authProvider(), config: conversationConfig)
129+
_ = try await client.startConversation(auth: authProvider(), config: effectiveConversationConfig)
110130
}
111131

112132
func endConversation() {

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)

0 commit comments

Comments
 (0)