diff --git a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx index 18d300495f..6ee0da2171 100644 --- a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx +++ b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx @@ -5,6 +5,8 @@ import { defineModal } from '@/lib/modals'; import { useDropzone } from 'react-dropzone'; import { useForm, useStore } from '@tanstack/react-form'; import { Image as ImageIcon } from 'lucide-react'; +import { tagsApi } from '@/lib/api'; +import { expandTagCommands } from '@/lib/tagExpansion'; import { Dialog, DialogContent, @@ -164,13 +166,20 @@ const TaskFormDialogImpl = NiceModal.create((props) => { // Form submission handler const handleSubmit = async ({ value }: { value: TaskFormValues }) => { + // Expand tag commands in description before submission + const tags = await tagsApi.list(); + const expandedDescription = await expandTagCommands( + value.description, + tags + ); + if (editMode) { await updateTask.mutateAsync( { taskId: props.task.id, data: { title: value.title, - description: value.description, + description: expandedDescription, status: value.status, parent_workspace_id: null, image_ids: images.length > 0 ? images.map((img) => img.id) : null, @@ -184,7 +193,7 @@ const TaskFormDialogImpl = NiceModal.create((props) => { const task = { project_id: projectId, title: value.title, - description: value.description, + description: expandedDescription, status: null, parent_workspace_id: mode === 'subtask' ? props.parentTaskAttemptId : null, diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx index a8510d5a4d..c744c33973 100644 --- a/frontend/src/components/tasks/TaskFollowUpSection.tsx +++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx @@ -52,12 +52,13 @@ import type { ExecutorAction, ExecutorProfileId, } from 'shared/types'; +import { expandTagCommands } from '@/lib/tagExpansion'; import { buildResolveConflictsInstructions } from '@/lib/conflicts'; import { useTranslation } from 'react-i18next'; import { useScratch } from '@/hooks/useScratch'; import { useDebouncedCallback } from '@/hooks/useDebouncedCallback'; import { useQueueStatus } from '@/hooks/useQueueStatus'; -import { imagesApi, attemptsApi } from '@/lib/api'; +import { imagesApi, attemptsApi, tagsApi } from '@/lib/api'; import { GitHubCommentsDialog } from '@/components/dialogs/tasks/GitHubCommentsDialog'; import type { NormalizedComment } from '@/components/ui/wysiwyg/nodes/github-comment-node'; import type { Session } from 'shared/types'; @@ -315,7 +316,7 @@ export function TaskFollowUpSection({ }); }, [entries]); - // Send follow-up action + // Send follow-up action with tag expansion const { isSendingFollowUp, followUpError, setFollowUpError, onSendFollowUp } = useFollowUpSend({ sessionId, @@ -331,6 +332,7 @@ export function TaskFollowUpSection({ setLocalMessage(''); // Clear local state immediately // Scratch deletion is handled by the backend when the queued message is consumed }, + expandTags: true, // Enable tag expansion for follow-up messages }); // Separate logic for when textarea should be disabled vs when send button should be disabled @@ -408,12 +410,16 @@ export function TaskFollowUpSection({ cancelDebouncedSave(); await saveToScratch(localMessage, selectedVariant); + // Expand tag commands before combining + const tags = await tagsApi.list(); + const expandedMessage = await expandTagCommands(localMessage, tags); + // Combine all the content that would be sent (same as follow-up send) const parts = [ conflictResolutionInstructions, clickedMarkdown, reviewMarkdown, - localMessage, + expandedMessage, ].filter(Boolean); const combinedMessage = parts.join('\n\n'); await queueMessage(combinedMessage, selectedVariant); diff --git a/frontend/src/components/ui/multi-file-search-textarea.tsx b/frontend/src/components/ui/multi-file-search-textarea.tsx index f43545cc8b..8318c19214 100644 --- a/frontend/src/components/ui/multi-file-search-textarea.tsx +++ b/frontend/src/components/ui/multi-file-search-textarea.tsx @@ -1,13 +1,9 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea'; -import { projectsApi } from '@/lib/api'; +import { searchTagsAndFiles, type SearchResultItem } from '@/lib/searchTagsAndFiles'; -import type { SearchResult } from 'shared/types'; - -interface FileSearchResult extends SearchResult { - name: string; -} +type SearchItem = SearchResultItem; interface MultiFileSearchTextareaProps { value: string; @@ -19,6 +15,7 @@ interface MultiFileSearchTextareaProps { projectId: string; onKeyDown?: (e: React.KeyboardEvent) => void; maxRows?: number; + enableTagCompletion?: boolean; } export function MultiFileSearchTextarea({ @@ -31,9 +28,10 @@ export function MultiFileSearchTextarea({ projectId, onKeyDown, maxRows = 10, + enableTagCompletion = true, }: MultiFileSearchTextareaProps) { const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); + const [searchResults, setSearchResults] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); const [currentTokenStart, setCurrentTokenStart] = useState(-1); @@ -43,7 +41,7 @@ export function MultiFileSearchTextarea({ const textareaRef = useRef(null); const dropdownRef = useRef(null); const abortControllerRef = useRef(null); - const searchCacheRef = useRef>(new Map()); + const searchCacheRef = useRef>(new Map()); const itemRefs = useRef>(new Map()); // Search for files when query changes @@ -63,7 +61,7 @@ export function MultiFileSearchTextarea({ return; } - const searchFiles = async () => { + const searchItems = async () => { setIsLoading(true); // Cancel previous request @@ -75,32 +73,20 @@ export function MultiFileSearchTextarea({ abortControllerRef.current = abortController; try { - const result = await projectsApi.searchFiles( - projectId, - searchQuery, - 'settings', - { - signal: abortController.signal, - } - ); + const results = await searchTagsAndFiles(searchQuery, projectId); // Only process if this request wasn't aborted if (!abortController.signal.aborted) { - const fileResults: FileSearchResult[] = result.map((item) => ({ - ...item, - name: item.path.split('/').pop() || item.path, - })); - // Cache the results - searchCacheRef.current.set(searchQuery, fileResults); + searchCacheRef.current.set(searchQuery, results); - setSearchResults(fileResults); - setShowDropdown(fileResults.length > 0); + setSearchResults(results); + setShowDropdown(results.length > 0); setSelectedIndex(-1); } } catch (error) { if (!abortController.signal.aborted) { - console.error('Failed to search files:', error); + console.error('Failed to search items:', error); } } finally { if (!abortController.signal.aborted) { @@ -109,7 +95,7 @@ export function MultiFileSearchTextarea({ } }; - const debounceTimer = setTimeout(searchFiles, 350); + const debounceTimer = setTimeout(searchItems, 350); return () => { clearTimeout(debounceTimer); if (abortControllerRef.current) { @@ -121,6 +107,18 @@ export function MultiFileSearchTextarea({ // Find current token boundaries based on cursor position const findCurrentToken = (text: string, cursorPosition: number) => { const textBefore = text.slice(0, cursorPosition); + + // Check for tag/command triggers (@ or /) + const tagMatch = textBefore.match(/[@/]([a-zA-Z0-9_-]*)$/); + if (tagMatch && enableTagCompletion) { + return { + token: tagMatch[1], + start: cursorPosition - tagMatch[0].length, + end: cursorPosition, + type: 'tag-command' as const, + }; + } + const textAfter = text.slice(cursorPosition); // Find the last separator (comma or newline) before cursor @@ -148,6 +146,7 @@ export function MultiFileSearchTextarea({ token, start: tokenStart, end: tokenEnd, + type: 'file' as const, }; }; @@ -158,13 +157,16 @@ export function MultiFileSearchTextarea({ onChange(newValue); - const { token, start, end } = findCurrentToken(newValue, cursorPosition); + const { token, start, end, type } = findCurrentToken(newValue, cursorPosition); setCurrentTokenStart(start); setCurrentTokenEnd(end); - // Show search results if token has 2+ characters - if (token.length >= 2) { + // For tag/command triggers, show search results immediately + if (type === 'tag-command' && enableTagCompletion) { + setSearchQuery(token); + } else if (token.length >= 2) { + // For file search, show results only if token has 2+ characters setSearchQuery(token); } else { setSearchQuery(''); @@ -193,7 +195,7 @@ export function MultiFileSearchTextarea({ case 'Tab': if (selectedIndex >= 0) { e.preventDefault(); - selectFile(searchResults[selectedIndex]); + selectItem(searchResults[selectedIndex]); return; } break; @@ -209,27 +211,35 @@ export function MultiFileSearchTextarea({ onKeyDown?.(e); }; - // Select a file and insert it into the text - const selectFile = (file: FileSearchResult) => { + // Select an item (tag or file) and insert it into the text + const selectItem = (item: SearchItem) => { if (currentTokenStart === -1) return; const before = value.slice(0, currentTokenStart); const after = value.slice(currentTokenEnd); - // Smart comma handling - add ", " if not at end and next char isn't comma/newline - let insertion = file.path; - const trimmedAfter = after.trimStart(); - const needsComma = - trimmedAfter.length > 0 && - !trimmedAfter.startsWith(',') && - !trimmedAfter.startsWith('\n'); - - if (needsComma || trimmedAfter.length === 0) { - insertion += ', '; + // Get the trigger character (@ or /) from the token start + const trigger = before.slice(-1); + + let insertion: string; + if (item.type === 'tag') { + // For tags, include the @ or / prefix + insertion = `${trigger}${item.tag!.tag_name} `; + } else { + // For files, smart comma handling + insertion = item.file!.path; + const trimmedAfter = after.trimStart(); + const needsComma = + trimmedAfter.length > 0 && + !trimmedAfter.startsWith(',') && + !trimmedAfter.startsWith('\n'); + + if (needsComma || trimmedAfter.length === 0) { + insertion += ', '; + } } - const newValue = - before.trimEnd() + (before.trimEnd() ? ' ' : '') + insertion + after; + const newValue = before + insertion + after; onChange(newValue); setShowDropdown(false); @@ -238,8 +248,7 @@ export function MultiFileSearchTextarea({ // Focus back to textarea and position cursor after insertion setTimeout(() => { if (textareaRef.current) { - const newCursorPos = - currentTokenStart + (before.trimEnd() ? 1 : 0) + insertion.length; + const newCursorPos = currentTokenStart + insertion.length; textareaRef.current.focus(); textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); } @@ -356,9 +365,9 @@ export function MultiFileSearchTextarea({ ) : (
- {searchResults.map((file, index) => ( + {searchResults.map((item, index) => (
{ if (el) itemRefs.current.set(index, el); else itemRefs.current.delete(index); @@ -368,12 +377,28 @@ export function MultiFileSearchTextarea({ ? 'bg-blue-50 text-blue-900' : 'hover:bg-muted' }`} - onClick={() => selectFile(file)} + onClick={() => selectItem(item)} > -
{file.name}
-
- {file.path} -
+ {item.type === 'tag' ? ( +
+
+ @ + {item.tag!.tag_name} +
+
+ {item.tag!.content.slice(0, 60)}... +
+
+ ) : ( +
+
+ {item.file!.name} +
+
+ {item.file!.path} +
+
+ )}
))}
diff --git a/frontend/src/components/ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx b/frontend/src/components/ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx index f9c3b72390..a99912293f 100644 --- a/frontend/src/components/ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx +++ b/frontend/src/components/ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx @@ -93,31 +93,47 @@ export function FileTagTypeaheadPlugin({ projectId }: { projectId?: string }) { return ( triggerFn={(text) => { - // Match @ followed by any non-whitespace characters - const match = /(?:^|\s)@([^\s@]*)$/.exec(text); + // Match @ or / followed by any non-whitespace characters + const match = /(?:^|\s)([@/])([^\s@/]*)$/.exec(text); if (!match) return null; - const offset = match.index + match[0].indexOf('@'); + const offset = match.index + match[0].indexOf(match[1]); return { leadOffset: offset, - matchingString: match[1], - replaceableString: match[0].slice(match[0].indexOf('@')), + matchingString: match[2], + replaceableString: match[0].slice(match[0].indexOf(match[1])), }; }} options={options} onQueryChange={onQueryChange} onSelectOption={(option, nodeToReplace, closeMenu) => { editor.update(() => { - const textToInsert = - option.item.type === 'tag' - ? (option.item.tag?.content ?? '') - : (option.item.file?.path ?? ''); + let textToInsert: string; + + // Get trigger character from text node + const nodeText = nodeToReplace?.getTextContent() || ''; + const triggerChar = nodeText.match(/[@/]/)?.[0] || '@'; + + if (option.item.type === 'tag') { + const tag = option.item.tag!; + + // If triggered by /, insert just /tag_name (for command syntax) + // If triggered by @, insert the full tag content + if (triggerChar === '/') { + textToInsert = `/${tag.tag_name} `; + } else { + textToInsert = tag.content || ''; + } + } else { + // For files, insert path + textToInsert = option.item.file?.path || ''; + } if (!nodeToReplace) return; // Create the node we want to insert const textNode = $createTextNode(textToInsert); - // Replace the trigger text (e.g., "@test") with selected content + // Replace the trigger text (e.g., "@test" or "/test") with selected content nodeToReplace.replace(textNode); // Move the cursor to the end of the inserted text diff --git a/frontend/src/hooks/useFollowUpSend.ts b/frontend/src/hooks/useFollowUpSend.ts index 4222488364..072dd67531 100644 --- a/frontend/src/hooks/useFollowUpSend.ts +++ b/frontend/src/hooks/useFollowUpSend.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from 'react'; -import { sessionsApi } from '@/lib/api'; +import { sessionsApi, tagsApi } from '@/lib/api'; import type { CreateFollowUpAttempt } from 'shared/types'; +import { expandTagCommands } from '@/lib/tagExpansion'; type Args = { sessionId?: string; @@ -12,6 +13,7 @@ type Args = { clearComments: () => void; clearClickedElements?: () => void; onAfterSendCleanup: () => void; + expandTags?: boolean; // Enable tag command expansion }; export function useFollowUpSend({ @@ -24,18 +26,31 @@ export function useFollowUpSend({ clearComments, clearClickedElements, onAfterSendCleanup, + expandTags = false, }: Args) { const [isSendingFollowUp, setIsSendingFollowUp] = useState(false); const [followUpError, setFollowUpError] = useState(null); const onSendFollowUp = useCallback(async () => { if (!sessionId) return; - const extraMessage = message.trim(); + + // Expand tag commands if enabled + let processedMessage = message.trim(); + if (expandTags && processedMessage) { + try { + const tags = await tagsApi.list(); + processedMessage = await expandTagCommands(processedMessage, tags); + } catch (error) { + console.error('Failed to expand tag commands:', error); + // Continue with unexpanded message if expansion fails + } + } + const finalPrompt = [ conflictMarkdown, clickedMarkdown?.trim(), reviewMarkdown?.trim(), - extraMessage, + processedMessage, ] .filter(Boolean) .join('\n\n'); @@ -73,6 +88,7 @@ export function useFollowUpSend({ clearComments, clearClickedElements, onAfterSendCleanup, + expandTags, ]); return { diff --git a/frontend/src/lib/tagExpansion.ts b/frontend/src/lib/tagExpansion.ts new file mode 100644 index 0000000000..0ddbf0596b --- /dev/null +++ b/frontend/src/lib/tagExpansion.ts @@ -0,0 +1,141 @@ +import type { Tag } from 'shared/types'; + +export interface ParsedTagCommand { + tagName: string; + args: string; + fullMatch: string; + startPos: number; + endPos: number; +} + +/** + * Parse text for /tag_name arguments patterns + * Supports multiple / commands in the same text + * + * @param text - Input text to parse + * @returns Array of parsed tag commands with their positions + */ +export function parseTagCommands(text: string): ParsedTagCommand[] { + const commands: ParsedTagCommand[] = []; + + // Remove Markdown escape characters (like \_) before matching + const unescapedText = text.replace(/\\_/g, '_'); + + // Pattern matches /tag_name arguments + // Simpler pattern: /tag_name followed by optional text + const pattern = /\/([a-zA-Z0-9_-]+)(?:\s+(.+?))?$/g; + + let match; + while ((match = pattern.exec(unescapedText)) !== null) { + const fullMatch = match[0]; + const tagName = match[1]; + const argumentsText = match[2]?.trim() || ''; + + commands.push({ + tagName, + args: argumentsText, + fullMatch, + startPos: match.index, + endPos: match.index + fullMatch.length, + }); + } + + return commands; +} + +/** + * Expand tag commands by replacing them with their content + * with $ARGUMENTS placeholders replaced + * + * @param text - Input text containing /tag_name commands + * @param tags - Array of available tags + * @returns Text with expanded tag commands + */ +export async function expandTagCommands( + text: string, + tags: Tag[] +): Promise { + // Remove Markdown escape characters before processing + const unescapedText = text.replace(/\\_/g, '_'); + + const commands = parseTagCommands(unescapedText); + if (commands.length === 0) return text; + + // Build a map of tag_name -> content for quick lookup + const tagMap = new Map(tags.map((t) => [t.tag_name, t.content])); + + // Replace commands from end to start to maintain correct positions + let result = unescapedText; + + // Sort commands by position in descending order + const sortedCommands = [...commands].sort((a, b) => b.startPos - a.startPos); + + for (const cmd of sortedCommands) { + const tagContent = tagMap.get(cmd.tagName); + + if (tagContent) { + // Replace placeholders with arguments + const expanded = replacePlaceholders(tagContent, cmd.args); + + // Replace the command with expanded content + const before = result.slice(0, cmd.startPos); + const after = result.slice(cmd.endPos); + result = before + expanded + after; + } + } + + return result; +} + +/** + * Replace placeholders in tag content with arguments + * Supported placeholders: + * - $ARGUMENTS + * - {args} + * - {{args}} + * + * @param content - Tag content with placeholders + * @param arguments - Arguments to replace placeholders with + * @returns Content with placeholders replaced + */ +export function replacePlaceholders( + content: string, + args: string +): string { + return content + .replace(/\$ARGUMENTS\b/g, args) + .replace(/\{args\}/g, args) + .replace(/\{\{args\}\}/g, args); +} + +/** + * Check if a tag contains any argument placeholders + * + * @param content - Tag content to check + * @returns True if content contains placeholders + */ +export function hasPlaceholders(content: string): boolean { + return /\$ARGUMENTS\b|\{args\}|\{\{args\}\}/.test(content); +} + +/** + * Extract tag names from text that contain placeholders + * + * @param text - Input text containing / commands + * @param tags - Array of available tags + * @returns Array of tag names that accept arguments + */ +export function extractTagsWithArguments( + text: string, + tags: Tag[] +): string[] { + const commands = parseTagCommands(text); + const tagMap = new Map(tags.map((t) => [t.tag_name, t.content])); + + return commands + .filter((cmd) => { + const content = tagMap.get(cmd.tagName); + return content && hasPlaceholders(content); + }) + .map((cmd) => cmd.tagName); +}