import CoreTransferable import Foundation import Observation import SwiftUI import UniformTypeIdentifiers #if os(macOS) import AppKit #endif #if !os(macOS) import PhotosUI #if canImport(UIKit) import UIKit /// The camera hands us an immutable snapshot, but UIKit does not expose it as /// Sendable. This wrapper is only used for one detached JPEG encoding pass. private struct OpenClawSendableCameraImage: @unchecked Sendable { let value: UIImage } private struct OpenClawVideoTransfer: Sendable, Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation(contentType: .movie) { video in SentTransferredFile(video.url) } importing: { received in let fileExtension = received.file.pathExtension let destination = FileManager.default.temporaryDirectory .appendingPathComponent("openclaw-picker-video-\(UUID().uuidString)") .appendingPathExtension(fileExtension.isEmpty ? "mov" : fileExtension) try FileManager.default.copyItem(at: received.file, to: destination) return OpenClawVideoTransfer(url: destination) } } } #endif @MainActor private struct OpenClawChatAttachmentCaptureOwner { let viewModel: OpenClawChatViewModel let session: OpenClawChatViewModel.SessionSnapshot } #endif private struct SlashPanelHeightKey: PreferenceKey { static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } struct OpenClawChatComposerPresentationOwner: Equatable { let viewModelID: ObjectIdentifier let session: OpenClawChatViewModel.SessionSnapshot @MainActor init(viewModel: OpenClawChatViewModel) { self.viewModelID = ObjectIdentifier(viewModel) self.session = viewModel.currentSessionSnapshot() } } @MainActor struct OpenClawChatComposer: View { @Bindable var viewModel: OpenClawChatViewModel let style: OpenClawChatView.Style let showsSessionSwitcher: Bool let userAccent: Color? let assistantName: String? let assistantAvatarText: String? let assistantAvatarTint: Color? let composerChrome: OpenClawChatView.ComposerChrome let isComposerEnabled: Bool let isAttachmentInputEnabled: Bool let messagePlaceholder: String? let talkControl: OpenClawChatTalkControl? let dictationControl: OpenClawChatDictationControl? let voiceNoteControl: OpenClawChatVoiceNoteControl? @State private var isSlashPopoverPresented = false @State private var suppressNextSlashPopoverUpdate = false @State private var slashPanelHeight: CGFloat = 0 @State private var slashHighlightIndex = 0 @State private var dictationTask: Task? #if !os(macOS) @State private var pickerItems: [PhotosPickerItem] = [] @State private var showsPhotoPicker = false @State private var showsFileImporter = false @State private var showsCameraPicker = false @State private var photoPickerOwner: OpenClawChatAttachmentCaptureOwner? @State private var fileImporterOwner: OpenClawChatAttachmentCaptureOwner? #if canImport(UIKit) @State private var cameraEncodingTask: Task? @State private var cameraEncodingGeneration = UUID() @State private var cameraCaptureOwner: OpenClawChatAttachmentCaptureOwner? #endif @FocusState private var isFocused: Bool #else @State private var shouldFocusTextView = false #endif @ScaledMetric(relativeTo: .body) private var scaledBodyLineHeight: CGFloat = 22 var body: some View { self.lifecycleComposer } private var styledComposer: some View { self.composerContent .padding(composerPadding) .background { self.composerBackground } } #if os(macOS) private var platformComposer: some View { self.styledComposer .onDrop(of: [.fileURL], isTargeted: nil) { providers in self.handleDrop(providers) } .onAppear { self.shouldFocusTextView = true self.viewModel.loadSlashCommandsIfNeeded() } } #else private var platformComposer: some View { self.styledComposer .onChange(of: self.isComposerEnabled) { _, isEnabled in if !isEnabled { self.isFocused = false self.setSlashPanelPresented(false) } } .onChange(of: self.isAttachmentInputEnabled) { _, isEnabled in if !isEnabled { self.showsPhotoPicker = false self.showsFileImporter = false self.showsCameraPicker = false self.photoPickerOwner = nil self.fileImporterOwner = nil #if canImport(UIKit) self.cameraCaptureOwner = nil #endif self.cancelActiveCameraEncoding() } } .onAppear { self.viewModel.loadSlashCommandsIfNeeded() } .fileImporter( isPresented: self.fileImporterPresentation, allowedContentTypes: OpenClawChatPickerAttachmentMetadata.allowedFileContentTypes, allowsMultipleSelection: true, onCompletion: { result in let owner = self.fileImporterOwner self.fileImporterOwner = nil self.handleFileImport(result, owner: owner) }) .photosPicker( isPresented: self.photoPickerPresentation, selection: self.$pickerItems, maxSelectionCount: 8, matching: .any(of: [.images, .videos])) .onChange(of: self.pickerItems) { _, items in guard !items.isEmpty else { return } let owner = self.photoPickerOwner self.photoPickerOwner = nil self.stagePhotosPickerItems(items, owner: owner) } #if canImport(UIKit) .fullScreenCover( isPresented: self.cameraPickerPresentation, onDismiss: { self.cameraCaptureOwner = nil }, content: { let owner = self.cameraCaptureOwner OpenClawChatCameraPicker { image in guard let owner else { return } self.addCameraImage(image, owner: owner) } .ignoresSafeArea() }) #endif } #endif private var recorderLifecycleComposer: some View { self.platformComposer .onChange(of: self.voiceNoteControl?.recorder.completedRecording) { _, recording in guard recording != nil else { return } self.stageCompletedVoiceNoteIfNeeded() } .onChange(of: self.voiceNoteControl?.recorder.ownsPendingChatAttachment) { _, _ in self.viewModel.attachmentOwnerActivityChanged() } .onChange(of: self.voiceNoteControl?.recorder.errorMessage) { _, message in if let message { self.viewModel.errorText = message } } } private var lifecycleComposer: some View { self.recorderLifecycleComposer .onChange(of: self.presentationOwner) { _, _ in ChatDictationActions.cancel(task: self.$dictationTask, control: self.dictationControl) #if !os(macOS) self.showsPhotoPicker = false self.showsFileImporter = false self.showsCameraPicker = false self.photoPickerOwner = nil self.fileImporterOwner = nil #if canImport(UIKit) self.cameraCaptureOwner = nil #endif self.cancelActiveCameraEncoding() #endif } .onAppear { self.viewModel.attachmentOwnerActivityChanged() self.stageCompletedVoiceNoteIfNeeded() } .onDisappear { ChatDictationActions.cancel(task: self.$dictationTask, control: self.dictationControl) #if !os(macOS) self.showsPhotoPicker = false self.showsFileImporter = false self.showsCameraPicker = false self.photoPickerOwner = nil self.fileImporterOwner = nil #if canImport(UIKit) self.cameraCaptureOwner = nil #endif self.cancelActiveCameraEncoding() #endif self.cancelActiveVoiceNoteIfNeeded() self.viewModel.attachmentOwnerActivityChanged() } } private var composerContent: some View { VStack(alignment: .leading, spacing: 4) { if self.composerChrome == .clean { self.cleanComposerCard } else { if self.showsToolbar, self.voiceNoteControl?.recorder.isRecording != true { self.composerToolbar } if self.showsAttachments, !self.viewModel.attachments.isEmpty { self.attachmentsStrip } self.composerContextRows if let voiceNoteControl, voiceNoteControl.recorder.isRecording { OpenClawVoiceNoteRecordingRow(recorder: voiceNoteControl.recorder) .padding(self.editorPadding) } else { self.editor } } } } @ViewBuilder private var composerContextRows: some View { if let replyTarget = self.viewModel.replyTarget { ChatReplyPreview(target: replyTarget) { self.viewModel.clearReplyTarget() } } if let talkControl, talkControl.isEnabled { ChatTalkActivityStrip(control: talkControl) } } private var presentationOwner: OpenClawChatComposerPresentationOwner { OpenClawChatComposerPresentationOwner(viewModel: self.viewModel) } #if !os(macOS) private func attachmentCaptureOwner() -> OpenClawChatAttachmentCaptureOwner { OpenClawChatAttachmentCaptureOwner( viewModel: self.viewModel, session: self.viewModel.currentSessionSnapshot()) } private var photoPickerPresentation: Binding { Binding( get: { self.showsPhotoPicker }, set: { isPresented in if isPresented { self.photoPickerOwner = self.attachmentCaptureOwner() } self.showsPhotoPicker = isPresented }) } private func presentPhotoPicker() { self.photoPickerOwner = self.attachmentCaptureOwner() self.showsPhotoPicker = true } private var fileImporterPresentation: Binding { Binding( get: { self.showsFileImporter }, set: { isPresented in if isPresented { self.fileImporterOwner = self.attachmentCaptureOwner() } self.showsFileImporter = isPresented }) } private var cameraPickerPresentation: Binding { Binding( get: { self.showsCameraPicker }, set: { isPresented in #if canImport(UIKit) if isPresented { self.cameraCaptureOwner = self.attachmentCaptureOwner() } #endif self.showsCameraPicker = isPresented #if canImport(UIKit) if !isPresented { self.cameraCaptureOwner = nil } #endif }) } #endif @ViewBuilder private var composerBackground: some View { if self.composerChrome == .full { let cornerRadius: CGFloat = 18 #if os(macOS) if self.style == .standard { let shape = UnevenRoundedRectangle( cornerRadii: RectangleCornerRadii( topLeading: 0, bottomLeading: cornerRadius, bottomTrailing: cornerRadius, topTrailing: 0), style: .continuous) shape .fill(OpenClawChatTheme.composerBackground) .overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1)) .shadow(color: .black.opacity(0.12), radius: 12, y: 6) } else { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) shape .fill(OpenClawChatTheme.composerBackground) .overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1)) .shadow(color: .black.opacity(0.12), radius: 12, y: 6) } #else let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) shape .fill(OpenClawChatTheme.composerBackground) .overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1)) .shadow(color: .black.opacity(0.12), radius: 12, y: 6) #endif } } private var composerToolbar: some View { HStack(spacing: 8) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 5) { if self.viewModel.sessionBranches.count > 1 { self.branchMenu } if self.showsSessionSwitcher { self.sessionPicker if self.viewModel.showsThinkingPicker { self.thinkingPicker } #if os(macOS) self.verbosityPicker if self.viewModel.selectedModelSupportsFastMode { self.fastModeToggle } #endif } if self.viewModel.showsModelPicker { self.modelPicker if self.viewModel.modelSelectionID != OpenClawChatViewModel.defaultModelSelectionID { self.modelPinButton } } } } Spacer(minLength: 4) if let fraction = self.viewModel.contextUsageFraction { self.contextUsageIndicator(fraction) } if self.style == .standard { self.refreshButton self.attachmentPicker if let voiceNoteControl, !voiceNoteControl.isTalkActive { OpenClawVoiceNoteButton( control: voiceNoteControl, compact: false, isComposerEnabled: self.isComposerEnabled, isAttachmentInputEnabled: self.isAttachmentInputEnabled) } } } .padding(.horizontal, 10) } private func contextUsageIndicator(_ fraction: Double) -> some View { let percentage = Int((fraction * 100).rounded()) let color = fraction >= 0.8 ? OpenClawChatTheme.warning : OpenClawChatTheme.muted return ZStack { Circle() .stroke(OpenClawChatTheme.muted.opacity(0.2), lineWidth: 2) Circle() .trim(from: 0, to: fraction) .stroke(color, style: StrokeStyle(lineWidth: 2, lineCap: .round)) .rotationEffect(.degrees(-90)) } .frame(width: 14, height: 14) .accessibilityElement(children: .ignore) .accessibilityLabel( String( format: String(localized: "Context %@%% used"), percentage.formatted())) } private var thinkingPicker: some View { Picker(selection: Binding( get: { self.viewModel.thinkingSelectionID }, set: { next in self.viewModel.selectThinkingLevel(next) })) { Text(String(localized: "Default (inherited)")) .font(OpenClawChatTypography.captionSemiBold) .tag(OpenClawChatViewModel.inheritedThinkingSelectionID) ForEach(self.viewModel.thinkingLevelOptions) { option in Text(String( format: String(localized: "%@ (override)"), option.label)) .font(OpenClawChatTypography.captionSemiBold) .tag(option.id) } } label: { Text("Thinking") .font(OpenClawChatTypography.captionSemiBold) } .labelsHidden() .pickerStyle(.menu) .controlSize(.small) .frame(maxWidth: 140, alignment: .leading) .disabled(self.viewModel.isUpdatingSessionSettings) } #if os(macOS) private var verbosityPicker: some View { Picker(selection: Binding( get: { self.viewModel.verboseLevel }, set: { self.viewModel.selectVerboseLevel($0) })) { Text(String(localized: "Default (inherited)")) .tag(OpenClawChatViewModel.inheritedThinkingSelectionID) Text(String(localized: "Off")).tag("off") Text(String(localized: "On")).tag("on") Text(String(localized: "Full")).tag("full") } label: { Text(String(localized: "Verbosity")) } .labelsHidden() .pickerStyle(.menu) .controlSize(.small) .help(String(localized: "Verbosity")) .disabled(self.viewModel.isUpdatingSessionSettings) } private var fastModeToggle: some View { Picker(selection: Binding( get: { self.viewModel.fastModeSelectionID }, set: { self.viewModel.selectFastMode($0) })) { Text(String(localized: "Default (inherited)")) .tag(OpenClawChatViewModel.inheritedThinkingSelectionID) Text(String(localized: "On")).tag("on") Text(String(localized: "Off")).tag("off") } label: { Label(String(localized: "Fast"), systemImage: "bolt.fill") } .labelsHidden() .pickerStyle(.menu) .controlSize(.small) .help(String(localized: "Fast responses")) .disabled(self.viewModel.isUpdatingSessionSettings) } #endif private var modelPicker: some View { // Sections come from an O(n) recompute over the catalog; bind once per body eval. let sections = self.viewModel.modelPickerSections return Picker(selection: Binding( get: { self.viewModel.modelSelectionID }, set: { next in self.viewModel.selectModel(next) })) { Text(self.viewModel.defaultModelLabel) .font(OpenClawChatTypography.captionSemiBold) .tag(OpenClawChatViewModel.defaultModelSelectionID) if !sections.pinned.isEmpty { Section { self.modelOptions(sections.pinned) } header: { Text("Pinned") .font(OpenClawChatTypography.captionSemiBold) } } if !sections.recent.isEmpty { Section { self.modelOptions(sections.recent) } header: { Text("Recent") .font(OpenClawChatTypography.captionSemiBold) } } ForEach(sections.providers) { provider in Section { self.modelOptions(provider.models) } header: { HStack(spacing: 4) { Text(provider.displayName) if provider.isDefaultProvider { Text(String(localized: "Default")) .font(OpenClawChatTypography.caption) .foregroundStyle(.secondary) } } } } } label: { Text("Model") .font(OpenClawChatTypography.captionSemiBold) } .labelsHidden() .pickerStyle(.menu) .controlSize(.small) .frame(maxWidth: 240, alignment: .leading) .help("Model") .disabled(self.viewModel.isUpdatingSessionSettings) } private func modelOptions(_ models: [OpenClawChatModelChoice]) -> some View { ForEach(models) { model in HStack(spacing: 4) { Text(model.displayLabel) .font(OpenClawChatTypography.captionSemiBold) if self.viewModel.isDefaultModel(model) { Text(String(localized: "Default")) .font(OpenClawChatTypography.caption) .foregroundStyle(.secondary) } } .tag(model.selectionID) } } private var modelPinButton: some View { Button { self.viewModel.toggleSelectedModelPinned() } label: { Image(systemName: self.viewModel.isSelectedModelPinned ? "star.fill" : "star") .font(.system(size: 12, weight: .semibold)) } .buttonStyle(.plain) .accessibilityLabel(self.viewModel.isSelectedModelPinned ? "Unpin model" : "Pin model") } private var sessionPicker: some View { Picker(selection: Binding( get: { self.viewModel.sessionKey }, set: { next in self.viewModel.switchSession(to: next) })) { ForEach(self.viewModel.sessionChoices, id: \.key) { session in Text(session.displayName ?? session.key) .font(OpenClawChatTypography.mono(size: 12, relativeTo: .caption)) .tag(session.key) } } label: { Text("Thread") .font(OpenClawChatTypography.captionSemiBold) } .labelsHidden() .pickerStyle(.menu) .controlSize(.small) .frame(maxWidth: 160, alignment: .leading) .help("Thread") } private var branchMenu: some View { Menu { ForEach(self.viewModel.sessionBranches) { branch in Button { guard !branch.active else { return } Task { await self.viewModel.switchToBranch(branch.leafEntryId) } } label: { HStack(spacing: 6) { VStack(alignment: .leading, spacing: 2) { Text(self.branchTitle(branch)) .font(OpenClawChatTypography.captionSemiBold) Text(self.branchMetadata(branch)) .font(OpenClawChatTypography.caption) .foregroundStyle(.secondary) } if branch.active { Image(systemName: "checkmark") } } } .disabled(branch.active) } .task { await self.viewModel.refreshSessionBranchesForMenuPresentation() } } label: { Image(systemName: "arrow.triangle.branch") } .buttonStyle(.bordered) .controlSize(.small) .help("Branches") .accessibilityLabel("Branches") .disabled(!self.viewModel.canSwitchSessionBranch) } private func branchTitle(_ branch: OpenClawChatSessionBranch) -> String { let headline = branch.headline.trimmingCharacters(in: .whitespacesAndNewlines) return headline.isEmpty ? String(localized: "Untitled branch") : headline } private func branchMetadata(_ branch: OpenClawChatSessionBranch) -> String { var parts = [Self.branchMessageCount(branch.messageCount)] if let updatedAt = branch.updatedAt, let date = try? Date(updatedAt, strategy: .iso8601) { parts.append(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))) } return parts.joined(separator: " · ") } static func branchMessageCount(_ count: Int) -> String { String(AttributedString(localized: "^[\(count) message](inflect: true)").characters) } @ViewBuilder private var attachmentPicker: some View { #if os(macOS) if self.composerChrome == .clean { Button { self.pickFilesMac() } label: { CompactChatAttachmentLabel() } .help("Add Attachment") .accessibilityLabel("Attachments") .accessibilityIdentifier("chat-attachment-picker") .buttonStyle(.plain) .controlSize(.small) .disabled(!self.isAttachmentInputEnabled) } else { Button { self.pickFilesMac() } label: { Image(systemName: "paperclip") } .help("Add Attachment") .accessibilityLabel("Attachments") .buttonStyle(.bordered) .controlSize(.small) .disabled(!self.isAttachmentInputEnabled) } #else if self.composerChrome == .clean { Button { self.presentPhotoPicker() } label: { CompactChatAttachmentLabel() } .help("Add Attachment") .accessibilityLabel("Attachments") .accessibilityIdentifier("chat-attachment-picker") .buttonStyle(.plain) .controlSize(.small) .disabled(!self.isAttachmentInputEnabled) } else { Button { self.presentPhotoPicker() } label: { Image(systemName: "paperclip") } .help("Add Attachment") .accessibilityLabel("Attachments") .buttonStyle(.bordered) .controlSize(.small) .disabled(!self.isAttachmentInputEnabled) } #endif } @ViewBuilder private var cleanAttachmentMenu: some View { #if os(macOS) self.attachmentPicker #else OpenClawChatAttachmentMenu( showsPhotoPicker: self.photoPickerPresentation, showsFileImporter: self.fileImporterPresentation, showsCameraPicker: self.cameraPickerPresentation, isAttachmentInputEnabled: self.isAttachmentInputEnabled) #endif } private var attachmentsStrip: some View { OpenClawChatAttachmentsStrip( attachments: self.viewModel.attachments, onRemove: { self.viewModel.removeAttachment($0) }) } private var editor: some View { self.editorContent .overlay(alignment: .top) { if self.isSlashPopoverPresented { self.slashCommandPanel .background( GeometryReader { geo in Color.clear.preference( key: SlashPanelHeightKey.self, value: geo.size.height) }) .offset(y: -(self.slashPanelHeight + 8)) .transition(.opacity) } } .onPreferenceChange(SlashPanelHeightKey.self) { newHeight in self.slashPanelHeight = newHeight } } @ViewBuilder private var editorContent: some View { if self.composerChrome == .clean { self.cleanEditor } else { self.fullEditor } } private var fullEditor: some View { VStack(alignment: .leading, spacing: 8) { self.editorOverlay Rectangle() .fill(OpenClawChatTheme.divider) .frame(height: 1) .padding(.horizontal, 2) HStack(alignment: .center, spacing: 8) { if let talkControl { ChatTalkButton( control: talkControl, sessionKey: self.viewModel.sessionKey, helpText: self.talkHelpText(talkControl), style: .full) if ChatCameraFlipButton.isAvailable(for: talkControl) { ChatCameraFlipButton(control: talkControl, size: 32) } } if self.showsConnectionPill { ChatConnectionPill( isConnected: self.viewModel.healthOK || (self.talkControl?.isGatewayConnected ?? false)) } Spacer(minLength: 0) self.sendButton } } .padding(.horizontal, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(OpenClawChatTheme.composerField) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(OpenClawChatTheme.composerBorder))) .padding(editorPadding) } private var cleanComposerCard: some View { VStack(alignment: .leading, spacing: 8) { if self.showsAttachments, !self.viewModel.attachments.isEmpty { self.attachmentsStrip } self.composerContextRows if let voiceNoteControl, voiceNoteControl.recorder.isRecording { OpenClawVoiceNoteRecordingRow( recorder: voiceNoteControl.recorder, embedded: true) } else { self.editor } } .padding(.horizontal, 6) .padding(.vertical, 2) .modifier(CleanChatComposerSurface(cornerRadius: 24)) .accessibilityElement(children: .contain) .accessibilityIdentifier("chat-composer-surface") } private var cleanEditor: some View { HStack(alignment: .center, spacing: 4) { self.cleanAttachmentMenu if self.viewModel.sessionBranches.count > 1 { self.branchMenu } self.editorOverlay .frame(maxWidth: .infinity, minHeight: self.cleanControlHeight, alignment: .leading) .layoutPriority(1) if self.dictationControl != nil || self.voiceNoteControl != nil { OpenClawChatMicButton( dictationControl: self.dictationControl, voiceNoteControl: self.voiceNoteControl, isDictationPending: self.dictationTask != nil, isRealtimeTalkActive: self.talkControl?.isEnabled == true, isComposerEnabled: self.isComposerEnabled, isAttachmentInputEnabled: self.isAttachmentInputEnabled, onCancelDictation: { ChatDictationActions.cancel(task: self.$dictationTask, control: self.dictationControl) }, onStartDictation: { if let dictationControl = self.dictationControl { ChatDictationActions.start( dictationControl, task: self.$dictationTask, viewModel: self.viewModel) } }) } if let talkControl, ChatCameraFlipButton.isAvailable(for: talkControl) { ChatCameraFlipButton(control: talkControl, size: self.cleanControlHeight) } self.cleanTrailingControl } } /// iMessage-style trailing control: the talk (mic) affordance while the /// draft is empty, swapping to the send button once the user types. @ViewBuilder private var cleanTrailingControl: some View { if Self.showsCompactTalkControl( hasDraftToSend: self.viewModel.hasDraftToSend, hasBlockingRunActivity: self.viewModel.hasBlockingRunActivity, isLocalVoiceCaptureActive: self.isLocalVoiceCaptureActive), let talkControl { ChatTalkButton( control: talkControl, sessionKey: self.viewModel.sessionKey, helpText: self.talkHelpText(talkControl), style: .compact( controlHeight: self.cleanControlHeight, iconControlSize: self.cleanIconControlSize)) } else { sendButton .frame(width: cleanControlHeight, height: cleanControlHeight) } } private var isLocalVoiceCaptureActive: Bool { self.dictationTask != nil || self.dictationControl?.isActive == true || self.voiceNoteControl?.recorder.isRecording == true || self.voiceNoteControl?.recorder.isRequestingPermission == true } private func talkHelpText(_ talkControl: OpenClawChatTalkControl) -> String { if !talkControl.isGatewayConnected, !talkControl.isEnabled { return "Connect the gateway before starting realtime chat" } let action = talkControl.isEnabled ? "Stop" : "Start" return "\(action) realtime chat for \(self.activeSessionLabel)" } private var activeSessionLabel: String { let match = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey } let trimmed = match?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? self.viewModel.sessionKey : trimmed } private var editorOverlay: some View { ZStack(alignment: editorOverlayAlignment) { if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(self.placeholderText) .font(OpenClawChatTypography.body) .foregroundStyle(.tertiary) .padding(.horizontal, self.cleanFieldTextInset) .padding(.vertical, self.composerChrome == .clean ? 0 : 4) } #if os(macOS) ChatComposerTextView( text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView, isEnabled: self.isComposerEnabled, onSend: { self.sendDraftIfEnabled() }, onPasteImageAttachment: { data, fileName, mimeType in guard self.isAttachmentInputEnabled else { return } self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType) }, onKeyCommand: { command, context in self.handleComposerKeyCommand(command, context: context) }) .frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) .padding(.horizontal, 4) .padding(.vertical, 3) .onChange(of: self.viewModel.input) { _, _ in self.updateSlashPopoverPresentation() } #elseif os(iOS) ChatComposerTextViewIOS( text: self.$viewModel.input, focusRequested: self.isFocused, isEnabled: self.isComposerEnabled, minHeight: self.textMinHeight, maxHeight: self.textMaxHeight, onFocusChange: { focused in self.isFocused = focused }, onHistoryUp: { !self.isSlashPopoverPresented && self.viewModel.recallPreviousInput(caretOnFirstLine: $0) }, onHistoryDown: { !self.isSlashPopoverPresented && self.viewModel.recallNextInput() }) .padding(.horizontal, self.cleanFieldTextInset) .padding(.vertical, self.composerChrome == .clean ? 0 : 6) .onChange(of: self.viewModel.input) { _, _ in self.updateSlashPopoverPresentation() } .onChange(of: self.isFocused) { _, focused in if focused { self.updateSlashPopoverPresentation() } else { self.setSlashPanelPresented(false) } } #else TextField( "", text: self.$viewModel.input, axis: .vertical) .font(OpenClawChatTypography.body) .textFieldStyle(.plain) .lineLimit(1...4) .fixedSize(horizontal: false, vertical: true) // iMessage-style: return inserts a newline; sending is the // circle button's job, so keep the standard return key. .submitLabel(.return) .padding(.horizontal, self.cleanFieldTextInset) .padding(.vertical, self.composerChrome == .clean ? 0 : 6) .focused(self.$isFocused) .disabled(!self.isComposerEnabled) .accessibilityIdentifier("chat-message-input") .onChange(of: self.viewModel.input) { _, _ in self.updateSlashPopoverPresentation() } .onChange(of: self.isFocused) { _, focused in if focused { self.updateSlashPopoverPresentation() } else { self.setSlashPanelPresented(false) } } // SwiftUI exposes neither the caret row nor soft-wrap geometry. // Start recall only from an empty draft; an active recall can // still walk both directions through the shared state machine. .onKeyPress(.upArrow) { guard !self.isSlashPopoverPresented else { return .ignored } return self.viewModel.recallPreviousInput(caretOnFirstLine: false) ? .handled : .ignored } .onKeyPress(.downArrow) { guard !self.isSlashPopoverPresented else { return .ignored } return self.viewModel.recallNextInput() ? .handled : .ignored } #endif } } } extension OpenClawChatComposer { private var slashQuery: String? { let text = self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines) guard text.hasPrefix("/"), !text.hasPrefix("//") else { return nil } let body = String(text.dropFirst()) guard !body.isEmpty else { return "" } let lower = body.lowercased() if lower == "skill" || lower.hasPrefix("skill ") { return body } if body.contains(where: \.isWhitespace) { return nil } return body } private var slashCommandPanel: some View { let query = self.slashQuery ?? "" let matches = self.viewModel.slashCommandMatches( query: query, filter: .all) return VStack(alignment: .leading, spacing: 0) { if self.viewModel.isLoadingSlashCommands, self.viewModel.slashCommands.isEmpty { HStack(spacing: 10) { ProgressView() Text("Loading commands") .font(OpenClawChatTypography.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 96) } else if let error = self.viewModel.slashCommandsErrorText, self.viewModel.slashCommands.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Commands unavailable") .font(OpenClawChatTypography.footnoteSemiBold) Text(error) .font(OpenClawChatTypography.caption) .foregroundStyle(.secondary) .lineLimit(3) Button { self.viewModel.refreshSlashCommands() } label: { Text("Retry") .font(OpenClawChatTypography.captionSemiBold) } .buttonStyle(.bordered) .controlSize(.small) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) } else if matches.isEmpty { Text("No matching commands") .font(OpenClawChatTypography.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, minHeight: 96) } else { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 2) { ForEach(Array(matches.enumerated()), id: \.element.id) { index, command in Button { self.selectSlashCommand(command) } label: { self.slashCommandRow( command, isHighlighted: self.usesSlashKeyboardHighlight && index == self.slashHighlightIndex) } .buttonStyle(.plain) .accessibilityLabel(command.displayInvocation) .id(index) .onHover { hovering in if hovering { self.slashHighlightIndex = index } } } } .padding(8) } .onChange(of: self.slashHighlightIndex) { _, index in proxy.scrollTo(index) } } .frame(maxHeight: .infinity) .overlay(alignment: .bottom) { if matches.count > 4 { self.slashCommandScrollAffordance } } } } .frame(height: 340) .background( .regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(Color.secondary.opacity(0.15), lineWidth: 0.5)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 12, y: 4) } private func slashCommandRow( _ command: OpenClawChatCommandChoice, isHighlighted: Bool) -> some View { HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: 3) { Text(command.displayInvocation) .font(OpenClawChatTypography.mono( size: 15, weight: .semibold, relativeTo: .subheadline)) .lineLimit(1) if !command.description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(command.description) .font(OpenClawChatTypography.caption) .foregroundStyle(.secondary) .lineLimit(2) } } Spacer(minLength: 0) } .padding(.horizontal, 8) .padding(.vertical, 8) .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(isHighlighted ? AnyShapeStyle(.selection) : AnyShapeStyle(.clear))) } private var slashCommandScrollAffordance: some View { VStack(spacing: 0) { Rectangle() .fill(.regularMaterial) .mask( LinearGradient( colors: [.clear, .black], startPoint: .top, endPoint: .bottom)) .frame(height: 34) Image(systemName: "chevron.compact.down") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.bottom, 6) .background(.regularMaterial) } .allowsHitTesting(false) } private func selectSlashCommand(_ command: OpenClawChatCommandChoice) { self.suppressNextSlashPopoverUpdate = true self.viewModel.applySlashCommandSelection(command) self.setSlashPanelPresented(false) #if os(macOS) self.shouldFocusTextView = true #else self.isFocused = true #endif } private func setSlashPanelPresented(_ presented: Bool) { withAnimation(.easeInOut(duration: 0.18)) { self.isSlashPopoverPresented = presented } if presented { self.slashHighlightIndex = 0 } } private var slashPanelCanPresent: Bool { // Transports without a command catalog (e.g. onboarding) get no panel // instead of an empty "No matching commands" box. guard self.viewModel.transport.supportsSlashCommandCatalog else { return false } // macOS input is an NSTextView outside SwiftUI focus tracking; it is // the composer's only editable field, so enablement is the gate. #if os(macOS) return self.isComposerEnabled #else return self.isComposerEnabled && self.isFocused #endif } /// Keyboard-driven row highlight is macOS-only; on touch platforms a /// persistent highlight on row 0 would read as a stray selection. private var usesSlashKeyboardHighlight: Bool { #if os(macOS) true #else false #endif } private func updateSlashPopoverPresentation() { if self.suppressNextSlashPopoverUpdate { self.suppressNextSlashPopoverUpdate = false return } let shouldShow = self.slashPanelCanPresent && self.slashQuery != nil if shouldShow { self.viewModel.loadSlashCommandsIfNeeded() self.slashHighlightIndex = 0 } if shouldShow != self.isSlashPopoverPresented { self.setSlashPanelPresented(shouldShow) } } #if os(macOS) /// Keyboard routing while the slash panel is open: arrows move the /// highlight, Tab/Return accept, Escape dismisses. Returning false hands /// the key back to the text view (typing, send-on-return). private func handleComposerKeyCommand( _ command: ChatComposerKeyCommand, context: ChatComposerKeyCommandContext) -> Bool { if self.isSlashPopoverPresented { let matches = self.viewModel.slashCommandMatches(query: self.slashQuery ?? "", filter: .all) switch command { case .escape: self.setSlashPanelPresented(false) return true case .moveUp: guard !matches.isEmpty else { return true } self.slashHighlightIndex = (self.slashHighlightIndex - 1 + matches.count) % matches.count return true case .moveDown: guard !matches.isEmpty else { return true } self.slashHighlightIndex = (self.slashHighlightIndex + 1) % matches.count return true case .tab, .returnKey: guard matches.indices.contains(self.slashHighlightIndex) else { self.setSlashPanelPresented(false) return command == .tab } self.selectSlashCommand(matches[self.slashHighlightIndex]) return true } } switch command { case .moveUp: return self.viewModel.recallPreviousInput(caretOnFirstLine: context.caretOnFirstLine) case .moveDown: return self.viewModel.recallNextInput() case .escape: if self.viewModel.cancelInputRecall() { return true } guard self.viewModel.replyTarget != nil else { return false } self.viewModel.clearReplyTarget() return true case .tab, .returnKey: return false } } #endif } extension OpenClawChatComposer { @ViewBuilder private var sendButton: some View { if self.viewModel.pendingRunCount > 0, self.shouldShowStopButton { Button { self.viewModel.abort() } label: { if self.viewModel.isAborting { ProgressView().controlSize(.mini) } else { Image(systemName: "stop.fill") .font(OpenClawChatTypography.display(size: 13, weight: .semibold, relativeTo: .caption)) } } .buttonStyle(.plain) .foregroundStyle(.white) .frame(width: self.sendButtonSize, height: self.sendButtonSize) .background( RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous) .fill(OpenClawChatTheme.danger) .frame(width: self.sendButtonVisualSize, height: self.sendButtonVisualSize)) .contentShape(Rectangle()) .accessibilityLabel("Stop response") .disabled(self.viewModel.isAborting) } else { Button { self.sendDraftIfEnabled() } label: { if self.viewModel.isSending { ProgressView().controlSize(.mini) } else { Image(systemName: "arrow.up") .font(OpenClawChatTypography.display(size: 13, weight: .semibold, relativeTo: .caption)) } } .buttonStyle(.plain) .foregroundStyle(self.sendButtonForeground) .frame(width: self.sendButtonSize, height: self.sendButtonSize) .background( RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous) .fill(self.canSendMessage ? self.sendButtonFill : self.disabledSendButtonFill) .frame(width: self.sendButtonVisualSize, height: self.sendButtonVisualSize)) .overlay( RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous) .strokeBorder(Color.white.opacity(self.sendButtonBorderOpacity), lineWidth: 1) .frame(width: self.sendButtonVisualSize, height: self.sendButtonVisualSize)) .contentShape(Rectangle()) .accessibilityLabel("Send message") .accessibilityIdentifier("chat-send-message") .disabled(!self.canSendMessage) } } private var refreshButton: some View { Button { self.viewModel.refresh() } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.bordered) .controlSize(.small) .help("Refresh") } private var showsToolbar: Bool { self.style == .standard && self.composerChrome == .full } private var showsAttachments: Bool { self.style == .standard } private var showsConnectionPill: Bool { self.style == .standard && self.composerChrome == .full } private var composerPadding: CGFloat { self.style == .onboarding ? 5 : (self.composerChrome == .clean ? 4 : 6) } private var editorPadding: CGFloat { self.style == .onboarding ? 5 : (self.composerChrome == .clean ? 4 : 6) } private var textMinHeight: CGFloat { let base: CGFloat = if self.style == .onboarding { 24 } else { self.composerChrome == .clean ? 24 : 28 } return max(base, self.scaledBodyLineHeight) } private var textMaxHeight: CGFloat { let base: CGFloat = if self.style == .onboarding { 52 } else { self.composerChrome == .clean ? 48 : 64 } return max(base, self.scaledBodyLineHeight * 4) } private var sendButtonSize: CGFloat { self.composerChrome == .clean ? self.cleanControlHeight : 44 } private var sendButtonVisualSize: CGFloat { self.composerChrome == .clean ? self.cleanIconControlSize : self.sendButtonSize } private var sendButtonCornerRadius: CGFloat { self.composerChrome == .clean ? self.cleanIconControlSize / 2 : 12 } private var cleanControlHeight: CGFloat { CleanChatComposerMetrics.controlHeight } private var cleanIconControlSize: CGFloat { 32 } private var cleanFieldTextInset: CGFloat { self.composerChrome == .clean ? 0 : 4 } private var editorOverlayAlignment: Alignment { self.composerChrome == .clean ? .leading : .topLeading } private var sendButtonFill: Color { self.userAccent ?? OpenClawChatTheme.userBubble } private var disabledSendButtonFill: Color { self.composerChrome == .clean ? .clear : Color.secondary.opacity(0.32) } private var sendButtonForeground: Color { if self.canSendMessage || self.composerChrome == .full { return .white } return .secondary.opacity(0.55) } private var sendButtonBorderOpacity: Double { if self.composerChrome == .clean, !self.canSendMessage { return 0 } return self.canSendMessage ? 0.18 : 0.08 } private var canSendMessage: Bool { self.isComposerEnabled // Dictation appends when capture completes; sending mid-capture // would split one draft across two messages. && self.dictationTask == nil && self.voiceNoteControl?.recorder.ownsPendingChatAttachment != true && self.viewModel.canSend && (self.isAttachmentInputEnabled || self.viewModel.attachments.isEmpty) } private func stageCompletedVoiceNoteIfNeeded() { guard let recorder = voiceNoteControl?.recorder, let recording = recorder.claimCompletedRecording() else { return } let viewModel = self.viewModel Task { await viewModel.addVoiceNoteAttachment( fileURL: recording.fileURL, durationSeconds: recording.durationSeconds) recorder.completeStaging(recording) } } private func cancelActiveVoiceNoteIfNeeded() { guard let recorder = voiceNoteControl?.recorder, recorder.isRecording || recorder.isRequestingPermission else { return } // The app-owned recorder outlives this view. Release the microphone // when its only recording UI disappears so capture never runs hidden. recorder.cancel() } private var placeholderText: String { let trimmed = self.messagePlaceholder?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? "Message…" : trimmed } #if os(macOS) private func pickFilesMac() { guard self.isAttachmentInputEnabled else { return } let panel = NSOpenPanel() panel.title = "Select attachments" panel.allowsMultipleSelection = true panel.canChooseDirectories = false panel.allowedContentTypes = OpenClawChatPickerAttachmentMetadata.allowedFileContentTypes panel.begin { resp in guard resp == .OK else { return } self.viewModel.addAttachments(urls: panel.urls) } } private func handleDrop(_ providers: [NSItemProvider]) -> Bool { guard self.isAttachmentInputEnabled else { return false } let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } guard !fileProviders.isEmpty else { return false } for item in fileProviders { item.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in guard let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return } Task { @MainActor in self.viewModel.addAttachments(urls: [url]) } } } return true } #else private func handleFileImport( _ result: Result<[URL], Error>, owner: OpenClawChatAttachmentCaptureOwner?) { guard self.isAttachmentInputEnabled else { return } guard let owner, self.viewModel === owner.viewModel, owner.viewModel.isCurrentSession(owner.session) else { return } switch result { case let .success(urls): owner.viewModel.addAttachments(urls: urls, for: owner.session) case let .failure(error): if !(error is CancellationError) { owner.viewModel.errorText = error.localizedDescription } } } #if canImport(UIKit) private func addCameraImage(_ image: UIImage, owner: OpenClawChatAttachmentCaptureOwner) { guard self.isAttachmentInputEnabled else { return } guard self.viewModel === owner.viewModel, owner.viewModel.isCurrentSession(owner.session) else { return } self.cancelActiveCameraEncoding() let sendableImage = OpenClawSendableCameraImage(value: image) let fileName = "camera-\(UUID().uuidString.prefix(8)).jpg" let viewModel = owner.viewModel let session = owner.session let generation = UUID() self.cameraEncodingGeneration = generation // Pin routing before JPEG encoding suspends so an external agent or // session update cannot adopt this camera result under a new owner. viewModel.beginAttachmentStaging() self.cameraEncodingTask = Task { @MainActor in defer { viewModel.endAttachmentStaging() } let data = await Task.detached(priority: .userInitiated) { sendableImage.value.jpegData(compressionQuality: 0.92) }.value guard !Task.isCancelled, self.cameraEncodingGeneration == generation, self.viewModel === viewModel, viewModel.isCurrentSession(session), self.isAttachmentInputEnabled, let data else { return } await viewModel.addImageAttachment( data: data, fileName: fileName, mimeType: "image/jpeg", for: session) } } #endif private func cancelActiveCameraEncoding() { #if canImport(UIKit) self.cameraEncodingGeneration = UUID() self.cameraEncodingTask?.cancel() self.cameraEncodingTask = nil #endif } private func stagePhotosPickerItems( _ items: [PhotosPickerItem], owner: OpenClawChatAttachmentCaptureOwner?) { guard self.isAttachmentInputEnabled else { self.pickerItems = [] return } guard let owner, self.viewModel === owner.viewModel, owner.viewModel.isCurrentSession(owner.session) else { self.pickerItems = [] return } owner.viewModel.beginAttachmentStaging() Task { @MainActor in defer { owner.viewModel.endAttachmentStaging() } await self.loadPhotosPickerItems(items, owner: owner) } } private func loadPhotosPickerItems( _ items: [PhotosPickerItem], owner: OpenClawChatAttachmentCaptureOwner) async { guard self.isAttachmentInputEnabled else { self.pickerItems = [] return } for item in items { do { guard self.viewModel === owner.viewModel, owner.viewModel.isCurrentSession(owner.session) else { break } let type = item.supportedContentTypes.first(where: { $0.conforms(to: .movie) || $0.conforms(to: .image) }) ?? item.supportedContentTypes.first ?? .image if type.conforms(to: .movie) { guard let transfer = try await item.loadTransferable(type: OpenClawVideoTransfer.self) else { continue } defer { try? FileManager.default.removeItem(at: transfer.url) } let metadata = OpenClawChatPickerAttachmentMetadata.resolve( contentType: type, transferredFileURL: transfer.url) let name = "video-\(UUID().uuidString.prefix(8)).\(metadata.fileExtension)" await owner.viewModel.addVideoAttachment( url: transfer.url, fileName: name, mimeType: metadata.mimeType, expectedSession: owner.session) } else { guard let data = try await item.loadTransferable(type: Data.self) else { continue } let metadata = OpenClawChatPickerAttachmentMetadata.resolve(contentType: type) let name = "photo-\(UUID().uuidString.prefix(8)).\(metadata.fileExtension)" await owner.viewModel.addImageAttachment( data: data, fileName: name, mimeType: metadata.mimeType, for: owner.session) } } catch { guard self.viewModel === owner.viewModel, owner.viewModel.isCurrentSession(owner.session) else { break } owner.viewModel.errorText = error.localizedDescription } } self.pickerItems = [] } #endif /// Preserve text and image draft controls while keeping completed voice notes abortable. private var shouldShowStopButton: Bool { !self.viewModel.hasDraftToSend || self.viewModel.attachments.contains { $0.mimeType == "audio/mp4" && $0.durationSeconds != nil } } private func sendDraftIfEnabled() { guard self.canSendMessage else { return } self.viewModel.send() } }