diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 900cb5081e5d..52d2c7c94cd5 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -20,7 +20,7 @@ import { SearchResult, } from "@src/utils/context-mentions" import { cn } from "@src/lib/utils" -import { convertToMentionPath } from "@src/utils/path-mentions" +import { convertToMentionPath, escapeSpaces } from "@src/utils/path-mentions" import { StandardTooltip } from "@src/components/ui" import Thumbnails from "../common/Thumbnails" @@ -342,6 +342,79 @@ export const ChatTextArea = forwardRef( } } + // Special handling for concrete folder selections: + // - Keep the picker open + // - Ensure trailing slash + // - Insert without trailing space + // - Trigger a follow-up search to show folder contents + if (type === ContextMenuOptionType.Folder && value && textAreaRef.current) { + // Normalize folder path to end with a trailing slash + let folderPath = value + if (!folderPath.endsWith("/")) { + folderPath = folderPath + "/" + } + + // Escape spaces for display (match insertMention behavior) + let displayFolderPath = folderPath + if (displayFolderPath.includes(" ") && !displayFolderPath.includes("\\ ")) { + displayFolderPath = escapeSpaces(displayFolderPath) + } + + // Manually build insertion without a trailing space (more deterministic than insertMention) + const original = textAreaRef.current.value + const beforeCursor = original.slice(0, cursorPosition) + const afterCursor = original.slice(cursorPosition) + const lastAtIndex = beforeCursor.lastIndexOf("@") + + let beforeMention = beforeCursor + let afterCursorContent = afterCursor + + if (lastAtIndex !== -1) { + // Replace everything from '@' to cursor with the folder path + beforeMention = original.slice(0, lastAtIndex) + // Match insertMention behavior for non-space-delimited languages + const isAlphaNumSpace = /^[a-zA-Z0-9\s]*$/.test(afterCursor) + afterCursorContent = isAlphaNumSpace ? afterCursor.replace(/^[^\s]*/, "") : afterCursor + } + + const updatedValue = beforeMention + "@" + displayFolderPath + afterCursorContent + const afterMentionPos = + (lastAtIndex !== -1 ? lastAtIndex : beforeCursor.length) + 1 + displayFolderPath.length + + setInputValue(updatedValue) + setCursorPosition(afterMentionPos) + setIntendedCursorPosition(afterMentionPos) + + // Keep the context menu open for drill-down + setShowContextMenu(true) + setSelectedType(null) + + // Compute the next-level query (text after '@' up to the caret) + const nextQuery = updatedValue.slice( + (lastAtIndex !== -1 ? lastAtIndex : beforeCursor.length) + 1, + afterMentionPos, + ) + setSearchQuery(nextQuery) + + // Kick off a search to populate folder children + const reqId = + globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` + setSearchRequestId(reqId) + setSearchLoading(true) + vscode.postMessage({ + type: "searchFiles", + query: unescapeSpaces(nextQuery), + requestId: reqId, + }) + + // Keep focus in the textarea for continued typing + setTimeout(() => { + textAreaRef.current?.focus() + }, 0) + + return + } + setShowContextMenu(false) setSelectedType(null) @@ -594,7 +667,9 @@ export const ChatTextArea = forwardRef( // Set a timeout to debounce the search requests. searchTimeoutRef.current = setTimeout(() => { // Generate a request ID for this search. - const reqId = Math.random().toString(36).substring(2, 9) + const reqId = + globalThis.crypto?.randomUUID?.() ?? + `${Date.now()}-${Math.random().toString(36).slice(2)}` setSearchRequestId(reqId) setSearchLoading(true) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.folder-drilldown.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.folder-drilldown.spec.tsx new file mode 100644 index 000000000000..00327ada50dc --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.folder-drilldown.spec.tsx @@ -0,0 +1,129 @@ +import React from "react" +import { render, fireEvent, screen } from "@src/utils/test-utils" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" +import { ContextMenuOptionType } from "@src/utils/context-mentions" +import { ChatTextArea } from "../ChatTextArea" + +// Mock VS Code messaging +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Capture the last props passed to ContextMenu so we can invoke onSelect directly +let lastContextMenuProps: any = null +vi.mock("../ContextMenu", () => { + return { + __esModule: true, + default: (props: any) => { + lastContextMenuProps = props + return
+ }, + __getLastProps: () => lastContextMenuProps, + } +}) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext") + +describe("ChatTextArea - folder drilldown behavior", () => { + const defaultProps = { + inputValue: "", + setInputValue: vi.fn(), + onSend: vi.fn(), + sendingDisabled: false, + selectApiConfigDisabled: false, + onSelectImages: vi.fn(), + shouldDisableImages: false, + placeholderText: "Type a message...", + selectedImages: [], + setSelectedImages: vi.fn(), + onHeightChange: vi.fn(), + mode: "architect", + setMode: vi.fn(), + modeShortcutText: "(⌘. for next mode)", + } + + beforeEach(() => { + vi.clearAllMocks() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: ["src/", "src/index.ts"], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + }) + + it("keeps picker open and triggers folder children search when selecting a folder", () => { + const setInputValue = vi.fn() + + const { container } = render() + + // Type to open the @-context menu and set a query + const textarea = container.querySelector("textarea")! + fireEvent.change(textarea, { + target: { value: "@s", selectionStart: 2 }, + }) + + // Ensure our mocked ContextMenu rendered and captured props + expect(screen.getByTestId("context-menu")).toBeInTheDocument() + const props = lastContextMenuProps + expect(props).toBeTruthy() + expect(typeof props.onSelect).toBe("function") + + // Simulate selecting a concrete folder suggestion (e.g. "/src") + props.onSelect(ContextMenuOptionType.Folder, "/src") + + // The input should contain "@/src/" with NO trailing space and the picker should remain open + expect(setInputValue).toHaveBeenCalled() + const finalValue = setInputValue.mock.calls.at(-1)?.[0] + expect(finalValue).toBe("@/src/") + + // Context menu should still be present (picker remains open) + expect(screen.getByTestId("context-menu")).toBeInTheDocument() + + // It should have kicked off a searchFiles request for the folder children + const pm = vscode.postMessage as ReturnType + expect(pm).toHaveBeenCalled() + const lastMsg = pm.mock.calls.at(-1)?.[0] + expect(lastMsg).toMatchObject({ type: "searchFiles" }) + // Query mirrors substring after '@' including leading slash per existing logic + expect(lastMsg.query).toBe("/src/") + expect(typeof lastMsg.requestId).toBe("string") + }) + + it("escapes spaces in input and sends unescaped query for folder with spaces", () => { + const setInputValue = vi.fn() + + const { container } = render() + + // Type to open the @-context menu and set a query + const textarea = container.querySelector("textarea")! + fireEvent.change(textarea, { + target: { value: "@m", selectionStart: 2 }, + }) + + // Ensure our mocked ContextMenu rendered and captured props + expect(screen.getByTestId("context-menu")).toBeInTheDocument() + const props = lastContextMenuProps + expect(props).toBeTruthy() + expect(typeof props.onSelect).toBe("function") + + // Simulate selecting a concrete folder with a space + props.onSelect(ContextMenuOptionType.Folder, "/my folder") + + // The input should contain the escaped path and NO trailing space + expect(setInputValue).toHaveBeenCalled() + const finalValue2 = setInputValue.mock.calls.at(-1)?.[0] + expect(finalValue2).toBe("@/my\\ folder/") + + // It should have kicked off a searchFiles request with unescaped query + const pm2 = vscode.postMessage as ReturnType + const lastMsg2 = pm2.mock.calls.at(-1)?.[0] + expect(lastMsg2).toMatchObject({ type: "searchFiles" }) + expect(lastMsg2.query).toBe("/my folder/") + expect(typeof lastMsg2.requestId).toBe("string") + }) +})