diff --git a/packages/ui/src/components/prompt-input/usePromptKeyDown.ts b/packages/ui/src/components/prompt-input/usePromptKeyDown.ts index 18d1746e..ab979216 100644 --- a/packages/ui/src/components/prompt-input/usePromptKeyDown.ts +++ b/packages/ui/src/components/prompt-input/usePromptKeyDown.ts @@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) { if (isDeletingFromEnd || isDeletingFromStart || isSelected) { const currentAttachments = options.getAttachments() - const attachment = currentAttachments.find( - (a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name, - ) + const attachment = currentAttachments.find((a) => { + if (a.source.type === "agent") { + return a.filename === name + } + if (a.source.type === "file") { + // Match either by filename (basename) or by path (for full paths like @docs/file.txt) + return ( + a.filename === name || + a.source.path === name || + a.source.path.endsWith("/" + name) || + a.source.path === name.replace(/\/$/, "") + ) + } + if (a.source.type === "text") { + // For text attachments (path-only mentions), match by value + return a.source.value === name || a.source.value.endsWith("/" + name) + } + return false + }) if (attachment) { e.preventDefault() @@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) { textarea.setSelectionRange(mentionStart, mentionStart) }, 0) + // Check if there are any @ remaining in the text - if not, close the picker + if (!newText.includes("@") && options.isPickerOpen()) { + options.closePicker() + // Clear ignoredAtPositions since we deleted the entire @mention + // This ensures typing @ again will open the picker + options.setIgnoredAtPositions(new Set()) + } + return } } diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts index 98e53bc6..6bd9957d 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -236,8 +236,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const mentionText = `@${folderMention}` if (action === "shiftEnter") { - // SHIFT+ENTER on directory: attach path as text only. - addPathOnlyAttachment(folderMention) + // SHIFT+ENTER on directory: keep @path in prompt (BACKSPACE works), remove @ when sending replaceMentionToken(mentionText, { trailingSpace: true }) } else { // ENTER/click on directory: attach as a file part pointing at a file:// directory URL. @@ -274,8 +273,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr } if (action === "shiftEnter") { - // SHIFT+ENTER on file: attach path as text only. - addPathOnlyAttachment(normalizedPath) + // SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) } else { // ENTER/click on file: attach file (existing behavior). @@ -316,6 +314,25 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const pos = atPosition() if (pickerMode() === "mention" && pos !== null) { setIgnoredAtPositions((prev) => new Set(prev).add(pos)) + + // Remove the partial @mention text from the textarea when ESC is pressed + const textarea = options.getTextarea() + if (textarea) { + const currentPrompt = options.prompt() + const cursorPos = textarea.selectionStart + // Remove text from @ position to cursor position + const before = currentPrompt.substring(0, pos) + const after = currentPrompt.substring(cursorPos) + options.setPrompt(before + after) + + // Restore cursor position to where @ was + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (nextTextarea) { + nextTextarea.setSelectionRange(pos, pos) + } + }, 0) + } } setShowPicker(false) setAtPosition(null) diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 7284d145..8799dffa 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -268,6 +268,13 @@ const UnifiedPicker: Component = (props) => { const workspaceChanged = lastWorkspaceId !== props.workspaceId const queryChanged = lastQuery !== props.searchQuery + if (queryChanged) { + // Reset selectedIndex to 0 when query changes to avoid ghost state + // This ensures proper highlighting when navigating back to root or changing queries + setSelectedIndex(0) + resetScrollPosition() + } + if (!isInitialized() || workspaceChanged || queryChanged) { setIsInitialized(true) lastWorkspaceId = props.workspaceId diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index 1d8ee1eb..594c6b4a 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -1,12 +1,34 @@ import type { Attachment } from "../types/attachment" export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string { - if (!prompt || !prompt.includes("[pasted #")) { + if (!prompt) { return prompt } + // First, strip @ from file/directory paths that don't have file attachments + // This handles SHIFT+ENTER case where @path should become path + const fileAttachments = new Set( + attachments + .filter((a) => a.source.type === "file" && "path" in a.source) + .map((a) => (a.source as { path: string }).path), + ) + + let result = prompt.replace(/@([^\s@]+)/g, (match, path) => { + // If this path has a file attachment, keep the @ (attachment is sent separately) + if (fileAttachments.has(path) || fileAttachments.has(path.replace(/\/$/, ""))) { + return match + } + // Otherwise (SHIFT+ENTER case), strip the @ + return path + }) + + // Then, resolve [pasted #N] placeholders + if (!result.includes("[pasted #")) { + return result + } + if (!attachments || attachments.length === 0) { - return prompt + return result } const lookup = new Map() @@ -26,10 +48,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen } if (lookup.size === 0) { - return prompt + return result } - return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { + return result.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { const replacement = lookup.get(fullMatch) return typeof replacement === "string" ? replacement : fullMatch })