diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b59c776..7b8b4f870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog). - macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb. - Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android). +- iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open. +- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently. +- Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs. +- iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled. - Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand. - Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast. - iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first). diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index aa77bd94d..396eb65e9 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -922,7 +922,7 @@ class NodeRuntime(context: Context) { private data class Quad(val first: A, val second: B, val third: C, val fourth: D) -private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF9EE0FF +private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF7FB8D4 private const val a2uiReadyCheckJS: String = """ 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 485014b1b..a404f0490 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 @@ -83,6 +83,9 @@ fun RootScreen(viewModel: MainViewModel) { val isForeground by viewModel.isForeground.collectAsState() val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() val talkEnabled by viewModel.talkEnabled.collectAsState() + val talkStatusText by viewModel.talkStatusText.collectAsState() + val talkIsListening by viewModel.talkIsListening.collectAsState() + val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() val seamColorArgb by viewModel.seamColorArgb.collectAsState() val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } val audioPermissionLauncher = @@ -267,6 +270,17 @@ fun RootScreen(viewModel: MainViewModel) { } } + if (talkEnabled) { + Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { + TalkOrbOverlay( + seamColor = seamColor, + statusText = talkStatusText, + isListening = talkIsListening, + isSpeaking = talkIsSpeaking, + ) + } + } + val currentSheet = sheet if (currentSheet != null) { ModalBottomSheet( diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/TalkOrbOverlay.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/TalkOrbOverlay.kt new file mode 100644 index 000000000..38b8e819e --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/TalkOrbOverlay.kt @@ -0,0 +1,134 @@ +package com.steipete.clawdis.node.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun TalkOrbOverlay( + seamColor: Color, + statusText: String, + isListening: Boolean, + isSpeaking: Boolean, + modifier: Modifier = Modifier, +) { + val transition = rememberInfiniteTransition(label = "talk-orb") + val t by + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "pulse", + ) + + val trimmed = statusText.trim() + val showStatus = trimmed.isNotEmpty() && trimmed != "Off" + val phase = + when { + isSpeaking -> "Speaking" + isListening -> "Listening" + else -> "Thinking" + } + + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.size(240.dp)) { + val center = this.center + val baseRadius = size.minDimension * 0.27f + + val ring1 = 1.05f + (t * 0.25f) + val ring2 = 1.20f + (t * 0.55f) + val ringAlpha1 = (1f - t) * 0.34f + val ringAlpha2 = (1f - t) * 0.22f + + drawCircle( + color = seamColor.copy(alpha = ringAlpha1), + radius = baseRadius * ring1, + center = center, + style = Stroke(width = 3.dp.toPx()), + ) + drawCircle( + color = seamColor.copy(alpha = ringAlpha2), + radius = baseRadius * ring2, + center = center, + style = Stroke(width = 3.dp.toPx()), + ) + + drawCircle( + brush = + Brush.radialGradient( + colors = + listOf( + seamColor.copy(alpha = 0.92f), + seamColor.copy(alpha = 0.40f), + Color.Black.copy(alpha = 0.56f), + ), + center = center, + radius = baseRadius * 1.35f, + ), + radius = baseRadius, + center = center, + ) + + drawCircle( + color = seamColor.copy(alpha = 0.34f), + radius = baseRadius, + center = center, + style = Stroke(width = 1.dp.toPx()), + ) + } + } + + if (showStatus) { + Surface( + color = Color.Black.copy(alpha = 0.40f), + shape = CircleShape, + ) { + Text( + text = trimmed, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + color = Color.White.copy(alpha = 0.92f), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } else { + Text( + text = phase, + color = Color.White.copy(alpha = 0.80f), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 02cf7c9af..26b5e4f1a 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -290,7 +290,7 @@ final class NodeAppModel { Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor } - private static let defaultSeamColor = Color(red: 0.62, green: 0.88, blue: 1.0) + private static let defaultSeamColor = Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0) private static func color(fromHex raw: String?) -> Color? { let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 17f02c79c..2ca28a15b 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -166,6 +166,12 @@ private struct CanvasContent: View { .padding(.top, 10) .padding(.trailing, 10) } + .overlay(alignment: .center) { + if self.appModel.talkMode.isEnabled { + TalkOrbOverlay() + .transition(.opacity) + } + } .overlay(alignment: .topLeading) { StatusPill( bridge: self.bridgeStatus, diff --git a/apps/ios/Sources/Voice/TalkOrbOverlay.swift b/apps/ios/Sources/Voice/TalkOrbOverlay.swift new file mode 100644 index 000000000..84a0b56c5 --- /dev/null +++ b/apps/ios/Sources/Voice/TalkOrbOverlay.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct TalkOrbOverlay: View { + @Environment(NodeAppModel.self) private var appModel + @State private var pulse: Bool = false + + var body: some View { + let seam = self.appModel.seamColor + let status = self.appModel.talkMode.statusText.trimmingCharacters(in: .whitespacesAndNewlines) + + VStack(spacing: 14) { + ZStack { + Circle() + .stroke(seam.opacity(0.26), lineWidth: 2) + .frame(width: 220, height: 220) + .scaleEffect(self.pulse ? 1.15 : 0.96) + .opacity(self.pulse ? 0.0 : 1.0) + .animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse) + + Circle() + .stroke(seam.opacity(0.18), lineWidth: 2) + .frame(width: 220, height: 220) + .scaleEffect(self.pulse ? 1.45 : 1.02) + .opacity(self.pulse ? 0.0 : 0.9) + .animation(.easeOut(duration: 1.9).repeatForever(autoreverses: false).delay(0.2), value: self.pulse) + + Circle() + .fill( + RadialGradient( + colors: [ + seam.opacity(0.95), + seam.opacity(0.40), + Color.black.opacity(0.55), + ], + center: .center, + startRadius: 1, + endRadius: 92)) + .frame(width: 136, height: 136) + .overlay( + Circle() + .stroke(seam.opacity(0.35), lineWidth: 1) + ) + .shadow(color: seam.opacity(0.32), radius: 26, x: 0, y: 0) + .shadow(color: Color.black.opacity(0.50), radius: 22, x: 0, y: 10) + } + .contentShape(Circle()) + .onTapGesture { + self.appModel.talkMode.userTappedOrb() + } + + if !status.isEmpty, status != "Off" { + Text(status) + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(Color.white.opacity(0.92)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.black.opacity(0.40)) + .overlay( + Capsule().stroke(seam.opacity(0.22), lineWidth: 1) + ) + ) + } + } + .padding(28) + .onAppear { + self.pulse = true + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Talk Mode \(status)") + } +} + diff --git a/apps/macos/Sources/Clawdis/TalkOverlay.swift b/apps/macos/Sources/Clawdis/TalkOverlay.swift index f36745df0..9ed8f2cb0 100644 --- a/apps/macos/Sources/Clawdis/TalkOverlay.swift +++ b/apps/macos/Sources/Clawdis/TalkOverlay.swift @@ -7,7 +7,7 @@ import SwiftUI @Observable final class TalkOverlayController { static let shared = TalkOverlayController() - static let overlaySize: CGFloat = 220 + static let overlaySize: CGFloat = 260 private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay") diff --git a/apps/macos/Sources/Clawdis/TalkOverlayView.swift b/apps/macos/Sources/Clawdis/TalkOverlayView.swift index d502e21ad..1dbc85277 100644 --- a/apps/macos/Sources/Clawdis/TalkOverlayView.swift +++ b/apps/macos/Sources/Clawdis/TalkOverlayView.swift @@ -26,21 +26,21 @@ struct TalkOverlayView: View { .frame(width: 18, height: 18) .background(Color.black.opacity(0.4)) .clipShape(Circle()) - } - .buttonStyle(.plain) - .contentShape(Circle()) - .offset(x: -10, y: -10) - .opacity(self.hoveringWindow ? 1 : 0) - .animation(.easeOut(duration: 0.12), value: self.hoveringWindow) - .allowsHitTesting(self.hoveringWindow) } + .buttonStyle(.plain) + .contentShape(Circle()) + .offset(x: -7, y: -7) + .opacity(self.hoveringWindow ? 1 : 0) + .animation(.easeOut(duration: 0.12), value: self.hoveringWindow) + .allowsHitTesting(self.hoveringWindow) + } } .frame(width: TalkOverlayController.overlaySize, height: TalkOverlayController.overlaySize, alignment: .center) .contentShape(Rectangle()) .onHover { self.hoveringWindow = $0 } } - private static let defaultSeamColor = Color(red: 0.62, green: 0.88, blue: 1.0) + private static let defaultSeamColor = Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0) private var seamColor: Color { Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor