import Foundation import Observation import SwiftUI #if !os(macOS) import PhotosUI import UniformTypeIdentifiers #endif @MainActor struct OpenClawChatComposer: View { @Bindable var viewModel: OpenClawChatViewModel let style: OpenClawChatView.Style let showsSessionSwitcher: Bool #if !os(macOS) @State private var pickerItems: [PhotosPickerItem] = [] @FocusState private var isFocused: Bool #else @State private var shouldFocusTextView = false #endif var body: some View { VStack(alignment: .leading, spacing: 4) { if self.showsToolbar { HStack(spacing: 6) { if self.showsSessionSwitcher { self.sessionPicker } self.thinkingPicker Spacer() self.refreshButton self.attachmentPicker } } if self.showsAttachments, !self.viewModel.attachments.isEmpty { self.attachmentsStrip } self.editor } .padding(self.composerPadding) .background { 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 } #if os(macOS) .onDrop(of: [.fileURL], isTargeted: nil) { providers in self.handleDrop(providers) } .onAppear { self.shouldFocusTextView = true } #endif } private var thinkingPicker: some View { Picker("Thinking", selection: self.$viewModel.thinkingLevel) { Text("Off").tag("off") Text("Low").tag("low") Text("Medium").tag("medium") Text("High").tag("high") } .labelsHidden() .pickerStyle(.menu) .controlSize(.small) .frame(maxWidth: 140, alignment: .leading) } private var sessionPicker: some View { Picker( "Session", 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(.system(.caption, design: .monospaced)) .tag(session.key) } } .labelsHidden() .pickerStyle(.menu) .controlSize(.small) .frame(maxWidth: 160, alignment: .leading) .help("Session") } @ViewBuilder private var attachmentPicker: some View { #if os(macOS) Button { self.pickFilesMac() } label: { Image(systemName: "paperclip") } .help("Add Image") .buttonStyle(.bordered) .controlSize(.small) #else PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) { Image(systemName: "paperclip") } .help("Add Image") .buttonStyle(.bordered) .controlSize(.small) .onChange(of: self.pickerItems) { _, newItems in Task { await self.loadPhotosPickerItems(newItems) } } #endif } private var attachmentsStrip: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach( self.viewModel.attachments, id: \OpenClawPendingAttachment.id) { (att: OpenClawPendingAttachment) in HStack(spacing: 6) { if let img = att.preview { OpenClawPlatformImageFactory.image(img) .resizable() .scaledToFill() .frame(width: 22, height: 22) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } else { Image(systemName: "photo") } Text(att.fileName) .lineLimit(1) Button { self.viewModel.removeAttachment(att.id) } label: { Image(systemName: "xmark.circle.fill") } .buttonStyle(.plain) } .padding(.horizontal, 8) .padding(.vertical, 5) .background(Color.accentColor.opacity(0.08)) .clipShape(Capsule()) } } } } private var editor: some View { VStack(alignment: .leading, spacing: 8) { self.editorOverlay if !self.isComposerCompacted { Rectangle() .fill(OpenClawChatTheme.divider) .frame(height: 1) .padding(.horizontal, 2) } HStack(alignment: .center, spacing: 8) { if self.showsConnectionPill { self.connectionPill } Spacer(minLength: 0) self.sendButton } } .padding(.horizontal, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(OpenClawChatTheme.composerField) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(OpenClawChatTheme.composerBorder))) .padding(self.editorPadding) } private var connectionPill: some View { HStack(spacing: 6) { Circle() .fill(self.viewModel.healthOK ? .green : .orange) .frame(width: 7, height: 7) Text(self.activeSessionLabel) .font(.caption2.weight(.semibold)) Text(self.viewModel.healthOK ? "Connected" : "Connecting…") .font(.caption2) .foregroundStyle(.secondary) } .padding(.horizontal, 8) .padding(.vertical, 4) .background(OpenClawChatTheme.subtleCard) .clipShape(Capsule()) } 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: .topLeading) { if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text("Message OpenClaw…") .foregroundStyle(.tertiary) .padding(.horizontal, 4) .padding(.vertical, 4) } #if os(macOS) ChatComposerTextView( text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView, onSend: { self.viewModel.send() }, onPasteImageAttachment: { data, fileName, mimeType in self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType) }) .frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) .padding(.horizontal, 4) .padding(.vertical, 3) #else TextEditor(text: self.$viewModel.input) .font(.system(size: 15)) .scrollContentBackground(.hidden) .frame( minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) .padding(.horizontal, 4) .padding(.vertical, 4) .focused(self.$isFocused) #endif } } private var sendButton: some View { Group { if self.viewModel.pendingRunCount > 0 { Button { self.viewModel.abort() } label: { if self.viewModel.isAborting { ProgressView().controlSize(.mini) } else { Image(systemName: "stop.fill") .font(.system(size: 13, weight: .semibold)) } } .buttonStyle(.plain) .foregroundStyle(.white) .padding(6) .background(Circle().fill(Color.red)) .disabled(self.viewModel.isAborting) } else { Button { self.viewModel.send() } label: { if self.viewModel.isSending { ProgressView().controlSize(.mini) } else { Image(systemName: "arrow.up") .font(.system(size: 13, weight: .semibold)) } } .buttonStyle(.plain) .foregroundStyle(.white) .padding(6) .background(Circle().fill(Color.accentColor)) .disabled(!self.viewModel.canSend) } } } 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.isComposerCompacted } private var showsAttachments: Bool { self.style == .standard } private var showsConnectionPill: Bool { self.style == .standard && !self.isComposerCompacted } private var composerPadding: CGFloat { self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) } private var editorPadding: CGFloat { self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) } private var textMinHeight: CGFloat { self.style == .onboarding ? 24 : 28 } private var textMaxHeight: CGFloat { self.style == .onboarding ? 52 : 64 } private var isComposerCompacted: Bool { #if os(macOS) false #else self.style == .standard && self.isFocused #endif } #if os(macOS) private func pickFilesMac() { let panel = NSOpenPanel() panel.title = "Select image attachments" panel.allowsMultipleSelection = true panel.canChooseDirectories = false panel.allowedContentTypes = [.image] panel.begin { resp in guard resp == .OK else { return } self.viewModel.addAttachments(urls: panel.urls) } } private func handleDrop(_ providers: [NSItemProvider]) -> Bool { 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 loadPhotosPickerItems(_ items: [PhotosPickerItem]) async { for item in items { do { guard let data = try await item.loadTransferable(type: Data.self) else { continue } let type = item.supportedContentTypes.first ?? .image let ext = type.preferredFilenameExtension ?? "jpg" let mime = type.preferredMIMEType ?? "image/jpeg" let name = "photo-\(UUID().uuidString.prefix(8)).\(ext)" self.viewModel.addImageAttachment(data: data, fileName: name, mimeType: mime) } catch { self.viewModel.errorText = error.localizedDescription } } self.pickerItems = [] } #endif } #if os(macOS) import AppKit import UniformTypeIdentifiers private struct ChatComposerTextView: NSViewRepresentable { @Binding var text: String @Binding var shouldFocus: Bool var onSend: () -> Void var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let textView = ChatComposerNSTextView() textView.delegate = context.coordinator textView.drawsBackground = false textView.isRichText = false textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false textView.font = .systemFont(ofSize: 14, weight: .regular) textView.textContainer?.lineBreakMode = .byWordWrapping textView.textContainer?.lineFragmentPadding = 0 textView.textContainerInset = NSSize(width: 2, height: 4) textView.focusRingType = .none textView.minSize = .zero textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.isHorizontallyResizable = false textView.isVerticallyResizable = true textView.autoresizingMask = [.width] textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) textView.textContainer?.widthTracksTextView = true textView.string = self.text textView.onSend = { [weak textView] in textView?.window?.makeFirstResponder(nil) self.onSend() } textView.onPasteImageAttachment = self.onPasteImageAttachment let scroll = NSScrollView() scroll.drawsBackground = false scroll.borderType = .noBorder scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.scrollerStyle = .overlay scroll.hasHorizontalScroller = false scroll.documentView = textView return scroll } func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return } textView.onPasteImageAttachment = self.onPasteImageAttachment if self.shouldFocus, let window = scrollView.window { window.makeFirstResponder(textView) self.shouldFocus = false } let isEditing = scrollView.window?.firstResponder == textView // Always allow clearing the text (e.g. after send), even while editing. // Only skip other updates while editing to avoid cursor jumps. let shouldClear = self.text.isEmpty && !textView.string.isEmpty if isEditing, !shouldClear { return } if textView.string != self.text { context.coordinator.isProgrammaticUpdate = true defer { context.coordinator.isProgrammaticUpdate = false } textView.string = self.text } } final class Coordinator: NSObject, NSTextViewDelegate { var parent: ChatComposerTextView var isProgrammaticUpdate = false init(_ parent: ChatComposerTextView) { self.parent = parent } func textDidChange(_ notification: Notification) { guard !self.isProgrammaticUpdate else { return } guard let view = notification.object as? NSTextView else { return } guard view.window?.firstResponder === view else { return } self.parent.text = view.string } } } private final class ChatComposerNSTextView: NSTextView { var onSend: (() -> Void)? var onPasteImageAttachment: ((_ data: Data, _ fileName: String, _ mimeType: String) -> Void)? override var readablePasteboardTypes: [NSPasteboard.PasteboardType] { var types = super.readablePasteboardTypes for type in ChatComposerPasteSupport.readablePasteboardTypes where !types.contains(type) { types.append(type) } return types } override func keyDown(with event: NSEvent) { let isReturn = event.keyCode == 36 if isReturn { if self.hasMarkedText() { super.keyDown(with: event) return } if event.modifierFlags.contains(.shift) { super.insertNewline(nil) return } self.onSend?() return } super.keyDown(with: event) } override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool { if !self.handleImagePaste(from: pboard, matching: type) { return super.readSelection(from: pboard, type: type) } return true } override func paste(_ sender: Any?) { if !self.handleImagePaste(from: NSPasteboard.general, matching: nil) { super.paste(sender) } } override func pasteAsPlainText(_ sender: Any?) { self.paste(sender) } private func handleImagePaste( from pasteboard: NSPasteboard, matching preferredType: NSPasteboard.PasteboardType?) -> Bool { let attachments = ChatComposerPasteSupport.imageAttachments(from: pasteboard, matching: preferredType) if !attachments.isEmpty { self.deliver(attachments) return true } let fileReferences = ChatComposerPasteSupport.imageFileReferences(from: pasteboard, matching: preferredType) if !fileReferences.isEmpty { self.loadAndDeliver(fileReferences) return true } return false } private func deliver(_ attachments: [ChatComposerPasteSupport.ImageAttachment]) { for attachment in attachments { self.onPasteImageAttachment?( attachment.data, attachment.fileName, attachment.mimeType) } } private func loadAndDeliver(_ fileReferences: [ChatComposerPasteSupport.FileImageReference]) { DispatchQueue.global(qos: .userInitiated).async { [weak self, fileReferences] in let attachments = ChatComposerPasteSupport.loadImageAttachments(from: fileReferences) guard !attachments.isEmpty else { return } DispatchQueue.main.async { guard let self else { return } self.deliver(attachments) } } } } enum ChatComposerPasteSupport { typealias ImageAttachment = (data: Data, fileName: String, mimeType: String) typealias FileImageReference = (url: URL, fileName: String, mimeType: String) static var readablePasteboardTypes: [NSPasteboard.PasteboardType] { [.fileURL] + self.preferredImagePasteboardTypes.map(\.type) } static func imageAttachments( from pasteboard: NSPasteboard, matching preferredType: NSPasteboard.PasteboardType? = nil) -> [ImageAttachment] { let dataAttachments = self.imageAttachmentsFromRawData(in: pasteboard, matching: preferredType) if !dataAttachments.isEmpty { return dataAttachments } if let preferredType, !self.matchesImageType(preferredType) { return [] } guard let images = pasteboard.readObjects(forClasses: [NSImage.self]) as? [NSImage], !images.isEmpty else { return [] } return images.enumerated().compactMap { index, image in self.imageAttachment(from: image, index: index) } } static func imageFileReferences( from pasteboard: NSPasteboard, matching preferredType: NSPasteboard.PasteboardType? = nil) -> [FileImageReference] { guard self.matchesFileURL(preferredType) else { return [] } return self.imageFileReferencesFromFileURLs(in: pasteboard) } static func loadImageAttachments(from fileReferences: [FileImageReference]) -> [ImageAttachment] { fileReferences.compactMap { reference in guard let data = try? Data(contentsOf: reference.url), !data.isEmpty else { return nil } return ( data: data, fileName: reference.fileName, mimeType: reference.mimeType) } } private static func imageFileReferencesFromFileURLs(in pasteboard: NSPasteboard) -> [FileImageReference] { guard let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty else { return [] } return urls.enumerated().compactMap { index, url -> FileImageReference? in guard url.isFileURL, let type = UTType(filenameExtension: url.pathExtension), type.conforms(to: .image) else { return nil } let mimeType = type.preferredMIMEType ?? "image/\(type.preferredFilenameExtension ?? "png")" let fileName = url.lastPathComponent.isEmpty ? self.defaultFileName(index: index, ext: type.preferredFilenameExtension ?? "png") : url.lastPathComponent return (url: url, fileName: fileName, mimeType: mimeType) } } private static func imageAttachmentsFromRawData( in pasteboard: NSPasteboard, matching preferredType: NSPasteboard.PasteboardType?) -> [ImageAttachment] { let items = pasteboard.pasteboardItems ?? [] guard !items.isEmpty else { return [] } return items.enumerated().compactMap { index, item in self.imageAttachment(from: item, index: index, matching: preferredType) } } private static func imageAttachment(from image: NSImage, index: Int) -> ImageAttachment? { guard let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else { return nil } if let pngData = bitmap.representation(using: .png, properties: [:]), !pngData.isEmpty { return ( data: pngData, fileName: self.defaultFileName(index: index, ext: "png"), mimeType: "image/png") } guard !tiffData.isEmpty else { return nil } return ( data: tiffData, fileName: self.defaultFileName(index: index, ext: "tiff"), mimeType: "image/tiff") } private static func imageAttachment( from item: NSPasteboardItem, index: Int, matching preferredType: NSPasteboard.PasteboardType?) -> ImageAttachment? { for type in self.preferredImagePasteboardTypes where self.matches(preferredType, candidate: type.type) { guard let data = item.data(forType: type.type), !data.isEmpty else { continue } return ( data: data, fileName: self.defaultFileName(index: index, ext: type.fileExtension), mimeType: type.mimeType) } return nil } private static let preferredImagePasteboardTypes: [ (type: NSPasteboard.PasteboardType, fileExtension: String, mimeType: String) ] = [ (.png, "png", "image/png"), (.tiff, "tiff", "image/tiff"), (NSPasteboard.PasteboardType("public.jpeg"), "jpg", "image/jpeg"), (NSPasteboard.PasteboardType("com.compuserve.gif"), "gif", "image/gif"), (NSPasteboard.PasteboardType("public.heic"), "heic", "image/heic"), (NSPasteboard.PasteboardType("public.heif"), "heif", "image/heif"), ] private static func matches(_ preferredType: NSPasteboard.PasteboardType?, candidate: NSPasteboard.PasteboardType) -> Bool { guard let preferredType else { return true } return preferredType == candidate } private static func matchesFileURL(_ preferredType: NSPasteboard.PasteboardType?) -> Bool { guard let preferredType else { return true } return preferredType == .fileURL } private static func matchesImageType(_ preferredType: NSPasteboard.PasteboardType) -> Bool { self.preferredImagePasteboardTypes.contains { $0.type == preferredType } } private static func defaultFileName(index: Int, ext: String) -> String { "pasted-image-\(index + 1).\(ext)" } } #endif