Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTalkActivityViews.swift
2026-07-17 12:16:44 -07:00

193 lines
7.1 KiB
Swift

import Foundation
import SwiftUI
struct ChatTalkTranscriptStripModel: Equatable {
let recentTranscript: [String]
let partialTranscript: String
init(recentTranscript: [String], partialTranscript: String, limit: Int = 20) {
let finals = recentTranscript
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
self.recentTranscript = Array(finals.suffix(max(0, limit)))
self.partialTranscript = partialTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
}
var hasTranscript: Bool {
!self.recentTranscript.isEmpty || !self.partialTranscript.isEmpty
}
var collapsedText: String? {
if !self.partialTranscript.isEmpty { return self.partialTranscript }
return self.recentTranscript.last
}
}
@MainActor
struct ChatTalkActivityStrip: View {
let control: OpenClawChatTalkControl
@State private var isExpanded = false
var body: some View {
let model = ChatTalkTranscriptStripModel(
recentTranscript: self.control.recentTranscript,
partialTranscript: self.control.partialTranscript)
if self.control.isEnabled {
VStack(alignment: .leading, spacing: 6) {
if model.hasTranscript {
Button {
withAnimation(.easeInOut(duration: 0.16)) {
self.isExpanded.toggle()
}
} label: {
self.header(model: model, showsDisclosure: true)
}
.buttonStyle(.plain)
.accessibilityLabel(self.isExpanded
? String(localized: "Hide talk transcript")
: String(localized: "Show talk transcript"))
} else {
self.header(model: model, showsDisclosure: false)
}
if self.isExpanded, model.hasTranscript {
ScrollView {
VStack(alignment: .leading, spacing: 5) {
ForEach(Array(model.recentTranscript.enumerated()), id: \.offset) { _, line in
Text(line)
.font(OpenClawChatTypography.caption)
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
}
if !model.partialTranscript.isEmpty {
Text(model.partialTranscript)
.font(OpenClawChatTypography.caption.italic())
.foregroundStyle(OpenClawChatTheme.accent)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxHeight: 104)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(OpenClawChatTheme.subtleCard))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
.padding(.horizontal, 14)
.accessibilityIdentifier("chat-talk-activity")
}
}
private func header(model: ChatTalkTranscriptStripModel, showsDisclosure: Bool) -> some View {
HStack(spacing: 7) {
Circle()
.fill(OpenClawChatTheme.accent)
.frame(width: 7, height: 7)
Text(self.control.statusText)
.font(OpenClawChatTypography.captionSemiBold)
.lineLimit(1)
if let collapsedText = model.collapsedText, !self.isExpanded {
Text(collapsedText)
.font(OpenClawChatTypography.caption)
.foregroundStyle(model.partialTranscript.isEmpty ? .secondary : OpenClawChatTheme.accent)
.italic(!model.partialTranscript.isEmpty)
.lineLimit(1)
}
Spacer(minLength: 0)
if showsDisclosure {
Image(systemName: self.isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
}
}
struct ChatTalkButtonGlyph: View {
let control: OpenClawChatTalkControl
private static let activePalette = TalkWaveformPalette(
active: [.white, .white.opacity(0.78), .white.opacity(0.55)],
inactive: [.white.opacity(0.65)])
var body: some View {
if self.control.isEnabled {
TalkWaveformView(phase: self.waveformPhase, palette: Self.activePalette)
.frame(width: 23, height: 16)
.allowsHitTesting(false)
} else {
Image(systemName: "waveform")
}
}
private var waveformPhase: TalkWaveformPhase {
if self.control.isListening {
return .listening(
level: self.control.level,
speechActive: !self.control.partialTranscript.isEmpty)
}
if self.control.isSpeaking {
return .speaking(level: self.control.level)
}
return .thinking
}
}
private struct ChatTalkInputDeviceMenuModifier: ViewModifier {
let control: OpenClawChatTalkControl
func body(content: Content) -> some View {
if self.control.selectInputDevice != nil {
content.contextMenu {
Button {
self.control.selectInputDevice?(nil)
} label: {
self.menuLabel(
String(localized: "System Default"),
selected: self.control.selectedInputDeviceID == nil)
}
if !self.control.inputDevices.isEmpty {
Divider()
ForEach(self.control.inputDevices) { device in
Button {
self.control.selectInputDevice?(device.id)
} label: {
self.menuLabel(
device.name,
selected: self.control.selectedInputDeviceID == device.id)
}
}
}
}
} else {
content
}
}
@ViewBuilder
private func menuLabel(_ text: String, selected: Bool) -> some View {
if selected {
Label(text, systemImage: "checkmark")
.font(OpenClawChatTypography.body)
} else {
Text(text)
.font(OpenClawChatTypography.body)
}
}
}
extension View {
func chatTalkInputDeviceMenu(_ control: OpenClawChatTalkControl) -> some View {
self.modifier(ChatTalkInputDeviceMenuModifier(control: control))
}
}