Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions Sources/AgentComposerTextView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import AppKit
import SwiftUI

struct AgentComposerTextView: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
@Binding var height: CGFloat

let fontSize: CGFloat
let minimumHeight: CGFloat
let maximumHeight: CGFloat
let isDisabled: Bool
let onSubmit: () -> Void

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeNSView(context: Context) -> NSScrollView {
let scrollView = AgentComposerScrollView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.horizontalScrollElasticity = .none
scrollView.wantsLayer = true
scrollView.layer?.masksToBounds = true
scrollView.contentView.drawsBackground = false
scrollView.contentView.wantsLayer = true
scrollView.contentView.layer?.masksToBounds = true
scrollView.contentHeightDidChange = { [weak coordinator = context.coordinator] contentHeight in
coordinator?.updateHeight(contentHeight: contentHeight)
}

let textView = NSTextView()
textView.drawsBackground = false
textView.backgroundColor = .clear
textView.delegate = context.coordinator
textView.font = .systemFont(ofSize: fontSize)
textView.textColor = .labelColor
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = true
textView.allowsUndo = true
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.textContainerInset = NSSize(width: 0, height: 2)
textView.textContainer?.lineFragmentPadding = 0
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.heightTracksTextView = false
textView.minSize = NSSize(width: 0, height: 0)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.autoresizingMask = [.width]

context.coordinator.textView = textView
context.coordinator.scrollView = scrollView
scrollView.documentView = textView
return scrollView
}

func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self
guard let textView = context.coordinator.textView else { return }

if textView.string != text {
textView.string = text
}

textView.font = .systemFont(ofSize: fontSize)
textView.isEditable = !isDisabled
textView.isSelectable = true
(scrollView as? AgentComposerScrollView)?.updateDocumentLayout()

if isDisabled,
textView.window?.firstResponder === textView {
textView.window?.makeFirstResponder(nil)
} else if isFocused,
textView.window?.firstResponder !== textView {
textView.window?.makeFirstResponder(textView)
}
}

final class Coordinator: NSObject, NSTextViewDelegate {
var parent: AgentComposerTextView
weak var textView: NSTextView?
weak var scrollView: NSScrollView?

init(_ parent: AgentComposerTextView) {
self.parent = parent
}

func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
parent.isFocused = true
parent.text = textView.string
(scrollView as? AgentComposerScrollView)?.updateDocumentLayout()
}

func textDidBeginEditing(_ notification: Notification) {
parent.isFocused = true
}

func textDidEndEditing(_ notification: Notification) {
parent.isFocused = false
}

func textView(
_ textView: NSTextView,
doCommandBy commandSelector: Selector
) -> Bool {
guard commandSelector == #selector(NSResponder.insertNewline(_:)) else {
return false
}

if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
return false
}

parent.onSubmit()
return true
}

func updateHeight(contentHeight: CGFloat) {
let nextHeight = min(max(contentHeight, parent.minimumHeight), parent.maximumHeight)
let shouldScroll = contentHeight > parent.maximumHeight + 0.5

if scrollView?.hasVerticalScroller != shouldScroll {
scrollView?.hasVerticalScroller = shouldScroll
}

guard abs(parent.height - nextHeight) > 0.5 else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if abs(self.parent.height - nextHeight) > 0.5 {
self.parent.height = nextHeight
}
}
}
}
}

private final class AgentComposerScrollView: NSScrollView {
var contentHeightDidChange: ((CGFloat) -> Void)?
private var lastReportedContentHeight: CGFloat = 0

override func layout() {
super.layout()
updateDocumentLayout()
}

func updateDocumentLayout() {
guard let textView = documentView as? NSTextView else { return }
let contentSize = contentView.bounds.size
let documentWidth = max(contentSize.width, 1)
let contentHeight = Self.contentHeight(for: textView, width: documentWidth)
let documentHeight = max(contentHeight, contentSize.height)
let targetSize = NSSize(width: documentWidth, height: documentHeight)

textView.minSize = NSSize(width: 0, height: contentSize.height)
textView.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.textContainer?.containerSize = NSSize(
width: documentWidth,
height: CGFloat.greatestFiniteMagnitude
)

if abs(textView.frame.width - targetSize.width) > 0.5
|| abs(textView.frame.height - targetSize.height) > 0.5 {
textView.setFrameSize(targetSize)
}

if abs(contentHeight - lastReportedContentHeight) > 0.5 {
lastReportedContentHeight = contentHeight
contentHeightDidChange?(contentHeight)
}
}

private static func contentHeight(for textView: NSTextView, width: CGFloat) -> CGFloat {
guard let textContainer = textView.textContainer,
let layoutManager = textView.layoutManager else {
return textView.textContainerInset.height * 2
}

textContainer.containerSize = NSSize(
width: max(width, 1),
height: CGFloat.greatestFiniteMagnitude
)
layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
return ceil(usedRect.height + (textView.textContainerInset.height * 2))
}
}
2 changes: 2 additions & 0 deletions Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,8 @@ final class AppState: ObservableObject, @unchecked Sendable {
title: title,
isLoading: isLoading
)
guard updatedPreview != preview else { return }

wordpressAgentPreview = updatedPreview
if let selectedWordPressAgentConversationID {
wordpressAgentPreviewsByConversationID[selectedWordPressAgentConversationID] = updatedPreview
Expand Down
89 changes: 60 additions & 29 deletions Sources/WordPressAgentUtilityOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct WordPressAgentUtilityOverlayView: View {

@State private var draftMessage = ""
@State private var pendingImageURLs: [URL] = []
@State private var composerTextHeight: CGFloat = 22
@FocusState private var isPromptFocused: Bool

private var selectedConversation: WordPressAgentConversation? {
Expand All @@ -35,28 +36,22 @@ struct WordPressAgentUtilityOverlayView: View {
}

var body: some View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
if !pendingImageURLs.isEmpty {
UtilityOverlayAttachmentStrip(fileURLs: pendingImageURLs) { url in
pendingImageURLs.removeAll { $0 == url }
}
}

TextField("Ask WordPress Agent", text: $draftMessage, axis: .vertical)
.textFieldStyle(.plain)
.font(.system(size: 15))
.lineLimit(1...4)
.focused($isPromptFocused)
.onSubmit(sendDraftMessage)
.disabled(isComposerDisabled)
composerTextView

HStack(spacing: 12) {
HStack(spacing: 10) {
Button {
selectImages()
} label: {
Image(systemName: "plus")
.font(.system(size: 20, weight: .regular))
.frame(width: 28, height: 28)
.font(.system(size: 17, weight: .regular))
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Expand All @@ -67,35 +62,36 @@ struct WordPressAgentUtilityOverlayView: View {
Button {
appState.showWordPressAgentWindow()
} label: {
HStack(spacing: 7) {
HStack(spacing: 5) {
Image(systemName: "globe")
.font(.system(size: 17, weight: .medium))
.font(.system(size: 15, weight: .medium))
Text(siteTitle)
.font(.system(size: 13, weight: .semibold))
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
}
.frame(maxWidth: 176, alignment: .leading)
.frame(maxWidth: 160, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Open WordPress Agent")

Spacer(minLength: 10)
Spacer(minLength: 8)

if selectedConversation?.isSending == true || appState.isTranscribing {
ProgressView()
.controlSize(.small)
.frame(width: 28, height: 28)
Image(systemName: "ellipsis")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 24, height: 24)
.help("Working")
}

Button {
appState.toggleRecording()
} label: {
Image(systemName: appState.isRecording ? "stop.circle.fill" : "mic")
.font(.system(size: 19, weight: .medium))
.frame(width: 28, height: 28)
.font(.system(size: 17, weight: .medium))
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Expand All @@ -107,9 +103,9 @@ struct WordPressAgentUtilityOverlayView: View {
sendDraftMessage()
} label: {
Image(systemName: "arrow.up")
.font(.system(size: 18, weight: .bold))
.font(.system(size: 16, weight: .bold))
.foregroundStyle(canSendMessage ? AgentPalette.primaryActionIcon : AgentPalette.secondaryText)
.frame(width: 36, height: 36)
.frame(width: 32, height: 32)
.background(
Circle()
.fill(canSendMessage ? AgentPalette.primaryActionFill : AgentPalette.disabledControl)
Expand All @@ -120,8 +116,8 @@ struct WordPressAgentUtilityOverlayView: View {
.disabled(!canSendMessage)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(width: 560, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
Expand All @@ -143,7 +139,7 @@ struct WordPressAgentUtilityOverlayView: View {
}

private var canSendMessage: Bool {
(!draftMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !pendingImageURLs.isEmpty)
(Self.containsNonWhitespace(draftMessage) || !pendingImageURLs.isEmpty)
&& !isComposerDisabled
}

Expand All @@ -153,22 +149,57 @@ struct WordPressAgentUtilityOverlayView: View {
|| appState.isTranscribing
}

private var composerTextView: some View {
ZStack(alignment: .topLeading) {
AgentComposerTextView(
text: $draftMessage,
isFocused: Binding(
get: { isPromptFocused },
set: { isPromptFocused = $0 }
),
height: $composerTextHeight,
fontSize: 15,
minimumHeight: 22,
maximumHeight: 160,
isDisabled: isComposerDisabled,
onSubmit: sendDraftMessage
)
.frame(height: composerTextHeight)

if draftMessage.isEmpty {
Text("Ask WordPress Agent")
.font(.system(size: 15))
.foregroundStyle(.tertiary)
.padding(.top, 4)
.allowsHitTesting(false)
}
}
}

private func sendDraftMessage() {
guard canSendMessage else { return }
let message = draftMessage
let attachments = pendingImageURLs
draftMessage = ""
pendingImageURLs = []
guard let conversationID = appState.submitWordPressAgentComposerMessage(
message,
attachments: attachments,
siteID: activeSiteID,
startsNewConversation: true
) else { return }
) else {
draftMessage = message
pendingImageURLs = attachments
return
}

draftMessage = ""
pendingImageURLs = []
onSubmit(conversationID)
}

private static func containsNonWhitespace(_ text: String) -> Bool {
text.contains { !$0.isWhitespace && !$0.isNewline }
}

private func selectImages() {
guard !isComposerDisabled else { return }

Expand Down
Loading
Loading