diff --git a/CHANGELOG.md b/CHANGELOG.md index 01821b781..cd94a3ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixes - macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background. +- macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries. - iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first). - macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries). - macOS menu: device list now shows connected nodes only. @@ -15,6 +16,7 @@ - iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts. - iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB. - iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states. +- iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch. - macOS menu: top status line now shows pending node pairing approvals (incl. repairs). - CLI: avoid spurious gateway close errors after successful request/response cycles. - Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections. diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt index cb11cf303..791f76325 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt @@ -13,6 +13,8 @@ import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebViewClient +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,6 +30,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material.icons.Icons @@ -72,6 +76,11 @@ fun RootScreen(viewModel: MainViewModel) { val screenRecordActive by viewModel.screenRecordActive.collectAsState() val isForeground by viewModel.isForeground.collectAsState() val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() + val talkEnabled by viewModel.talkEnabled.collectAsState() + val audioPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) viewModel.setTalkEnabled(true) + } val activity = remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) { // Status pill owns transient activity state so it doesn't overlap the connection indicator. @@ -211,6 +220,30 @@ fun RootScreen(viewModel: MainViewModel) { icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") }, ) + // Talk mode gets a dedicated side bubble instead of burying it in settings. + OverlayIconButton( + onClick = { + val next = !talkEnabled + if (next) { + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + viewModel.setTalkEnabled(true) + } else { + viewModel.setTalkEnabled(false) + } + }, + icon = { + val tint = if (talkEnabled) MaterialTheme.colorScheme.primary else LocalContentColor.current + Icon( + Icons.Default.RecordVoiceOver, + contentDescription = "Talk Mode", + tint = tint, + ) + }, + ) + OverlayIconButton( onClick = { sheet = Sheet.Settings }, icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt index 2ec4a7119..c7d011892 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt @@ -62,8 +62,6 @@ fun SettingsSheet(viewModel: MainViewModel) { val wakeWords by viewModel.wakeWords.collectAsState() val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val talkEnabled by viewModel.talkEnabled.collectAsState() - val talkStatusText by viewModel.talkStatusText.collectAsState() val isConnected by viewModel.isConnected.collectAsState() val manualEnabled by viewModel.manualEnabled.collectAsState() val manualHost by viewModel.manualHost.collectAsState() @@ -309,28 +307,6 @@ fun SettingsSheet(viewModel: MainViewModel) { // Voice item { Text("Voice", style = MaterialTheme.typography.titleSmall) } - item { - ListItem( - headlineContent = { Text("Talk Mode") }, - supportingContent = { Text(talkStatusText) }, - trailingContent = { - Switch( - checked = talkEnabled, - onCheckedChange = { on -> - if (on) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setTalkEnabled(true) - } else { - viewModel.setTalkEnabled(false) - } - }, - ) - }, - ) - } item { val enabled = voiceWakeMode != VoiceWakeMode.Off ListItem( diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index b55f84cc1..910d96a3d 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -120,6 +120,8 @@ struct RootCanvas: View { private struct CanvasContent: View { @Environment(NodeAppModel.self) private var appModel + @AppStorage("talk.enabled") private var talkEnabled: Bool = false + @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true var systemColorScheme: ColorScheme var bridgeStatus: StatusPill.BridgeState var voiceWakeEnabled: Bool @@ -141,6 +143,19 @@ private struct CanvasContent: View { } .accessibilityLabel("Chat") + if self.talkButtonEnabled { + // Talk mode lives on a side bubble so it doesn't get buried in settings. + OverlayButton( + systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle", + brighten: self.brightenButtons) + { + let next = !self.appModel.talkMode.isEnabled + self.talkEnabled = next + self.appModel.setTalkEnabled(next) + } + .accessibilityLabel("Talk Mode") + } + OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { self.openSettings() } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 265b7069c..34b05dfc9 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -21,6 +21,7 @@ struct SettingsTab: View { @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false @AppStorage("talk.enabled") private var talkEnabled: Bool = false + @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @AppStorage("camera.enabled") private var cameraEnabled: Bool = true @AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" @@ -161,6 +162,8 @@ struct SettingsTab: View { .onChange(of: self.talkEnabled) { _, newValue in self.appModel.setTalkEnabled(newValue) } + // Keep this separate so users can hide the side bubble without disabling Talk Mode. + Toggle("Show Talk Button", isOn: self.$talkButtonEnabled) NavigationLink { VoiceWakeWordsSettingsView()