diff --git a/desktop/Desktop/Sources/FloatingControlBar/AskAIInputView.swift b/desktop/Desktop/Sources/FloatingControlBar/AskAIInputView.swift index faa02b10c97..7ef0c14284e 100644 --- a/desktop/Desktop/Sources/FloatingControlBar/AskAIInputView.swift +++ b/desktop/Desktop/Sources/FloatingControlBar/AskAIInputView.swift @@ -6,6 +6,7 @@ struct AskAIInputView: View { @Binding var userInput: String @State private var localInput: String = "" @State private var textHeight: CGFloat = 40 + @State private var hasMarkedText = false var canClearVisibleConversation: Bool = false var onSend: ((String) -> Void)? @@ -40,7 +41,7 @@ struct AskAIInputView: View { HStack(spacing: 6) { ZStack(alignment: .topLeading) { - if localInput.isEmpty { + if localInput.isEmpty && !hasMarkedText { Text("Ask a question...") .scaledFont(size: 13) .foregroundColor(.secondary) @@ -57,6 +58,7 @@ struct AskAIInputView: View { onSend?(trimmed) }, focusOnAppear: true, + onMarkedTextChange: { hasMarkedText = $0 }, minHeight: minHeight, maxHeight: maxHeight, onHeightChange: { newHeight in diff --git a/desktop/Desktop/Sources/MainWindow/Components/ChatInputView.swift b/desktop/Desktop/Sources/MainWindow/Components/ChatInputView.swift index 672c9f22a2b..5f574584288 100644 --- a/desktop/Desktop/Sources/MainWindow/Components/ChatInputView.swift +++ b/desktop/Desktop/Sources/MainWindow/Components/ChatInputView.swift @@ -37,6 +37,7 @@ struct ChatInputView: View { @AppStorage("askModeEnabled") private var askModeEnabled = false @Environment(\.fontScale) private var fontScale @State private var isDropTargeted = false + @State private var hasMarkedText = false private var hasText: Bool { !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -89,7 +90,7 @@ struct ChatInputView: View { .accessibilityHidden(true) .overlay(alignment: .topLeading) { // Placeholder text — padding matches textContainerInset exactly - if inputText.isEmpty { + if inputText.isEmpty && !hasMarkedText { Text(placeholder) .scaledFont(size: 14) .foregroundColor(OmiColors.textTertiary) @@ -104,7 +105,8 @@ struct ChatInputView: View { fontSize: round(14 * fontScale), textColor: NSColor(OmiColors.textPrimary), textContainerInset: NSSize(width: inputPaddingH, height: inputPaddingV), - onSubmit: handleSubmit + onSubmit: handleSubmit, + onMarkedTextChange: { hasMarkedText = $0 } ) } .frame(maxHeight: 200) diff --git a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistantSettings.swift b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistantSettings.swift index f9c24f0b72b..fc84b5f4bdc 100644 --- a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistantSettings.swift +++ b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistantSettings.swift @@ -170,6 +170,14 @@ class TaskAssistantSettings { - Questions expecting an answer: "What's the status of…?", "When will you…?" - Assigned items: "@user", "assigned to you", review requests + CRITICAL FOR PUBLIC/GROUP CHANNELS: + In Discord, Slack, Teams, community chats, and other public/group channels, extract ONLY when the visible evidence shows the user is directly involved: + - The message explicitly @mentions the user by name or handle + - It is a direct message (DM) thread, not a public or community channel + - The user has already replied in the same thread and is an active participant + If the user is merely observing a public channel, or if you cannot tell whether the request is directed at them, call no_task_found. + Do NOT extract tasks from broad bug reports, feature requests, or questions posted to the community at large. + WHO COUNTS AS "SOMEONE": - A coworker in Slack, Teams, Discord, email - A friend/family member in iMessage, WhatsApp, Telegram, Messenger diff --git a/desktop/Desktop/Sources/Theme/OmiTextEditor.swift b/desktop/Desktop/Sources/Theme/OmiTextEditor.swift index ff026a00e31..b9198d16741 100644 --- a/desktop/Desktop/Sources/Theme/OmiTextEditor.swift +++ b/desktop/Desktop/Sources/Theme/OmiTextEditor.swift @@ -17,6 +17,30 @@ private class AutoFocusScrollView: NSScrollView { } } +/// NSTextView subclass that reports IME marked-text composition state. +private class OmiNSTextView: NSTextView { + var onMarkedTextStatusChange: ((Bool) -> Void)? + + override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) + publishMarkedTextStatus() + } + + override func unmarkText() { + super.unmarkText() + publishMarkedTextStatus() + } + + override func insertText(_ string: Any, replacementRange: NSRange) { + super.insertText(string, replacementRange: replacementRange) + publishMarkedTextStatus() + } + + private func publishMarkedTextStatus() { + onMarkedTextStatusChange?(hasMarkedText()) + } +} + /// Unified NSTextView wrapper used by both the main chat input and the floating control bar. struct OmiTextEditor: NSViewRepresentable { @Binding var text: String @@ -30,6 +54,7 @@ struct OmiTextEditor: NSViewRepresentable { // Behavior var onSubmit: (() -> Void)? = nil var focusOnAppear: Bool = true + var onMarkedTextChange: ((Bool) -> Void)? = nil // Optional height tracking (for floating bar's window resize flow) var minHeight: CGFloat? = nil @@ -37,7 +62,7 @@ struct OmiTextEditor: NSViewRepresentable { var onHeightChange: ((CGFloat) -> Void)? = nil func makeNSView(context: Context) -> NSScrollView { - let textView = NSTextView() + let textView = OmiNSTextView() textView.font = .systemFont(ofSize: fontSize) textView.textColor = textColor textView.backgroundColor = .clear @@ -48,6 +73,9 @@ struct OmiTextEditor: NSViewRepresentable { textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.delegate = context.coordinator + textView.onMarkedTextStatusChange = { [weak coordinator = context.coordinator] hasMarkedText in + coordinator?.updateMarkedTextState(hasMarkedText) + } textView.textContainer?.lineFragmentPadding = lineFragmentPadding textView.textContainerInset = textContainerInset @@ -87,7 +115,7 @@ struct OmiTextEditor: NSViewRepresentable { // correct task's draftText when SwiftUI reuses this NSView across tasks. context.coordinator.updateTextBinding($text) - if textView.string != text { + if textView.string != text, !textView.hasMarkedText() { context.coordinator.isUpdating = true textView.string = text context.coordinator.isUpdating = false @@ -124,11 +152,14 @@ struct OmiTextEditor: NSViewRepresentable { // Keep closures fresh so they capture the latest SwiftUI state context.coordinator.onSubmit = onSubmit + context.coordinator.onMarkedTextChange = onMarkedTextChange let newFont = NSFont.systemFont(ofSize: fontSize) if textView.font != newFont { textView.font = newFont } + + context.coordinator.updateMarkedTextState(textView.hasMarkedText()) } /// Return a concrete size to SwiftUI's layout engine so it doesn't have to @@ -156,6 +187,7 @@ struct OmiTextEditor: NSViewRepresentable { Coordinator( text: $text, onSubmit: onSubmit, + onMarkedTextChange: onMarkedTextChange, minHeight: minHeight, maxHeight: maxHeight, onHeightChange: onHeightChange @@ -165,6 +197,7 @@ struct OmiTextEditor: NSViewRepresentable { class Coordinator: NSObject, NSTextViewDelegate { @Binding var text: String var onSubmit: (() -> Void)? + var onMarkedTextChange: ((Bool) -> Void)? var isUpdating = false func updateTextBinding(_ binding: Binding) { @@ -176,16 +209,19 @@ struct OmiTextEditor: NSViewRepresentable { private let maxHeight: CGFloat? private let onHeightChange: ((CGFloat) -> Void)? private var lastHeight: CGFloat = 0 + private var lastMarkedTextState = false init( text: Binding, onSubmit: (() -> Void)?, + onMarkedTextChange: ((Bool) -> Void)?, minHeight: CGFloat?, maxHeight: CGFloat?, onHeightChange: ((CGFloat) -> Void)? ) { self._text = text self.onSubmit = onSubmit + self.onMarkedTextChange = onMarkedTextChange self.minHeight = minHeight self.maxHeight = maxHeight self.onHeightChange = onHeightChange @@ -198,9 +234,15 @@ struct OmiTextEditor: NSViewRepresentable { if onHeightChange != nil, let scrollView = textView.enclosingScrollView { updateHeight(for: textView, scrollView: scrollView) } + + updateMarkedTextState(textView.hasMarkedText()) } func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if textView.hasMarkedText() { + return false + } + if commandSelector == #selector(NSResponder.insertNewline(_:)) { let flags = NSApp.currentEvent?.modifierFlags ?? [] if !flags.contains(.shift) { @@ -228,5 +270,14 @@ struct OmiTextEditor: NSViewRepresentable { onHeightChange(constrainedHeight) } } + + func updateMarkedTextState(_ hasMarkedText: Bool) { + guard hasMarkedText != lastMarkedTextState else { return } + lastMarkedTextState = hasMarkedText + DispatchQueue.main.async { [weak self] in + guard self?.lastMarkedTextState == hasMarkedText else { return } + self?.onMarkedTextChange?(hasMarkedText) + } + } } } diff --git a/desktop/Desktop/Tests/TaskAssistantPromptTests.swift b/desktop/Desktop/Tests/TaskAssistantPromptTests.swift new file mode 100644 index 00000000000..ddc2f70f4a9 --- /dev/null +++ b/desktop/Desktop/Tests/TaskAssistantPromptTests.swift @@ -0,0 +1,14 @@ +import XCTest +@testable import Omi_Computer + +final class TaskAssistantPromptTests: XCTestCase { + @MainActor + func testDefaultPromptSkipsPublicChannelRequestsNotDirectedAtUser() { + let prompt = TaskAssistantSettings.defaultAnalysisPrompt + + XCTAssertTrue(prompt.contains("CRITICAL FOR PUBLIC/GROUP CHANNELS")) + XCTAssertTrue(prompt.contains("visible evidence shows the user is directly involved")) + XCTAssertTrue(prompt.contains("call no_task_found")) + XCTAssertTrue(prompt.contains("questions posted to the community at large")) + } +}