fix(webview-ui): keep @ context picker open when selecting a folder via Enter/Tab

Insert folder mentions without trailing space so the picker remains active. Keep the picker open and immediately query folder children to enable keyboard drilldown. Add a focused test for folder drilldown behavior. Fixes #8076.
This commit is contained in:
Hannes Rudolph 2025-09-24 09:35:34 -06:00
parent 205f3e4242
commit 3cbb73bc86
2 changed files with 162 additions and 0 deletions

View file

@ -342,6 +342,72 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}
// 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 + "/"
}
// 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 + "@" + folderPath + afterCursorContent
const afterMentionPos =
(lastAtIndex !== -1 ? lastAtIndex : beforeCursor.length) + 1 + folderPath.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 = Math.random().toString(36).substring(2, 9)
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)

View file

@ -0,0 +1,96 @@
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 <div data-testid="context-menu" />
},
__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<typeof vi.fn>).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(<ChatTextArea {...defaultProps} setInputValue={setInputValue} />)
// 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<typeof vi.fn>
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")
})
})