Skip to content
Open
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
13 changes: 11 additions & 2 deletions frontend/src/components/dialogs/tasks/TaskFormDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -164,13 +166,20 @@ const TaskFormDialogImpl = NiceModal.create<TaskFormDialogProps>((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,
Expand All @@ -184,7 +193,7 @@ const TaskFormDialogImpl = NiceModal.create<TaskFormDialogProps>((props) => {
const task = {
project_id: projectId,
title: value.title,
description: value.description,
description: expandedDescription,
status: null,
parent_workspace_id:
mode === 'subtask' ? props.parentTaskAttemptId : null,
Expand Down
12 changes: 9 additions & 3 deletions frontend/src/components/tasks/TaskFollowUpSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
133 changes: 79 additions & 54 deletions frontend/src/components/ui/multi-file-search-textarea.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,6 +15,7 @@ interface MultiFileSearchTextareaProps {
projectId: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
maxRows?: number;
enableTagCompletion?: boolean;
}

export function MultiFileSearchTextarea({
Expand All @@ -31,9 +28,10 @@ export function MultiFileSearchTextarea({
projectId,
onKeyDown,
maxRows = 10,
enableTagCompletion = true,
}: MultiFileSearchTextareaProps) {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([]);
const [searchResults, setSearchResults] = useState<SearchItem[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [currentTokenStart, setCurrentTokenStart] = useState(-1);
Expand All @@ -43,7 +41,7 @@ export function MultiFileSearchTextarea({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const searchCacheRef = useRef<Map<string, FileSearchResult[]>>(new Map());
const searchCacheRef = useRef<Map<string, SearchItem[]>>(new Map());
const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map());

// Search for files when query changes
Expand All @@ -63,7 +61,7 @@ export function MultiFileSearchTextarea({
return;
}

const searchFiles = async () => {
const searchItems = async () => {
setIsLoading(true);

// Cancel previous request
Expand All @@ -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) {
Expand All @@ -109,7 +95,7 @@ export function MultiFileSearchTextarea({
}
};

const debounceTimer = setTimeout(searchFiles, 350);
const debounceTimer = setTimeout(searchItems, 350);
return () => {
clearTimeout(debounceTimer);
if (abortControllerRef.current) {
Expand All @@ -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
Expand Down Expand Up @@ -148,6 +146,7 @@ export function MultiFileSearchTextarea({
token,
start: tokenStart,
end: tokenEnd,
type: 'file' as const,
};
};

Expand All @@ -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('');
Expand Down Expand Up @@ -193,7 +195,7 @@ export function MultiFileSearchTextarea({
case 'Tab':
if (selectedIndex >= 0) {
e.preventDefault();
selectFile(searchResults[selectedIndex]);
selectItem(searchResults[selectedIndex]);
return;
}
break;
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -356,9 +365,9 @@ export function MultiFileSearchTextarea({
</div>
) : (
<div className="py-1">
{searchResults.map((file, index) => (
{searchResults.map((item, index) => (
<div
key={file.path}
key={item.type === 'tag' ? `tag-${item.tag!.id}` : `file-${item.file!.path}`}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
Expand All @@ -368,12 +377,28 @@ export function MultiFileSearchTextarea({
? 'bg-blue-50 text-blue-900'
: 'hover:bg-muted'
}`}
onClick={() => selectFile(file)}
onClick={() => selectItem(item)}
>
<div className="font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground truncate">
{file.path}
</div>
{item.type === 'tag' ? (
<div>
<div className="font-medium flex items-center gap-2">
<span className="text-muted-foreground">@</span>
{item.tag!.tag_name}
</div>
<div className="text-xs text-muted-foreground truncate">
{item.tag!.content.slice(0, 60)}...
</div>
</div>
) : (
<div>
<div className="font-medium truncate">
{item.file!.name}
</div>
<div className="text-xs text-muted-foreground truncate">
{item.file!.path}
</div>
</div>
)}
</div>
))}
</div>
Expand Down
Loading