Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down Expand Up @@ -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)
Expand All @@ -57,6 +58,7 @@ struct AskAIInputView: View {
onSend?(trimmed)
},
focusOnAppear: true,
onMarkedTextChange: { hasMarkedText = $0 },
minHeight: minHeight,
maxHeight: maxHeight,
onHeightChange: { newHeight in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 53 additions & 2 deletions desktop/Desktop/Sources/Theme/OmiTextEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,14 +54,15 @@ 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
var maxHeight: CGFloat? = nil
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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -156,6 +187,7 @@ struct OmiTextEditor: NSViewRepresentable {
Coordinator(
text: $text,
onSubmit: onSubmit,
onMarkedTextChange: onMarkedTextChange,
minHeight: minHeight,
maxHeight: maxHeight,
onHeightChange: onHeightChange
Expand All @@ -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<String>) {
Expand All @@ -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<String>,
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
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
}
}
}
14 changes: 14 additions & 0 deletions desktop/Desktop/Tests/TaskAssistantPromptTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
Comment on lines +9 to +13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Shallow string-presence assertions

The test only verifies that four literal substrings exist inside defaultAnalysisPrompt. It acts as a deletion guard but cannot catch prompt regressions where the wording is reordered, split across lines, or rephrased while losing semantic meaning — all of those would still pass. If the intent is a prompt-regression test, consider also asserting the key logical conditions in the right order (e.g. that the @mention bullet appears before the DM bullet, or that no_task_found follows the public-channel guard block), which would catch structural rearrangements that break the LLM's interpretation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}
Loading