Skip to content

Commit 3cbb73b

Browse files
committed
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.
1 parent 205f3e4 commit 3cbb73b

File tree

2 files changed

+162
-0
lines changed

2 files changed

+162
-0
lines changed

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,72 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
342342
}
343343
}
344344

345+
// Special handling for concrete folder selections:
346+
// - Keep the picker open
347+
// - Ensure trailing slash
348+
// - Insert without trailing space
349+
// - Trigger a follow-up search to show folder contents
350+
if (type === ContextMenuOptionType.Folder && value && textAreaRef.current) {
351+
// Normalize folder path to end with a trailing slash
352+
let folderPath = value
353+
if (!folderPath.endsWith("/")) {
354+
folderPath = folderPath + "/"
355+
}
356+
357+
// Manually build insertion without a trailing space (more deterministic than insertMention)
358+
const original = textAreaRef.current.value
359+
const beforeCursor = original.slice(0, cursorPosition)
360+
const afterCursor = original.slice(cursorPosition)
361+
const lastAtIndex = beforeCursor.lastIndexOf("@")
362+
363+
let beforeMention = beforeCursor
364+
let afterCursorContent = afterCursor
365+
366+
if (lastAtIndex !== -1) {
367+
// Replace everything from '@' to cursor with the folder path
368+
beforeMention = original.slice(0, lastAtIndex)
369+
// Match insertMention behavior for non-space-delimited languages
370+
const isAlphaNumSpace = /^[a-zA-Z0-9\s]*$/.test(afterCursor)
371+
afterCursorContent = isAlphaNumSpace ? afterCursor.replace(/^[^\s]*/, "") : afterCursor
372+
}
373+
374+
const updatedValue = beforeMention + "@" + folderPath + afterCursorContent
375+
const afterMentionPos =
376+
(lastAtIndex !== -1 ? lastAtIndex : beforeCursor.length) + 1 + folderPath.length
377+
378+
setInputValue(updatedValue)
379+
setCursorPosition(afterMentionPos)
380+
setIntendedCursorPosition(afterMentionPos)
381+
382+
// Keep the context menu open for drill-down
383+
setShowContextMenu(true)
384+
setSelectedType(null)
385+
386+
// Compute the next-level query (text after '@' up to the caret)
387+
const nextQuery = updatedValue.slice(
388+
(lastAtIndex !== -1 ? lastAtIndex : beforeCursor.length) + 1,
389+
afterMentionPos,
390+
)
391+
setSearchQuery(nextQuery)
392+
393+
// Kick off a search to populate folder children
394+
const reqId = Math.random().toString(36).substring(2, 9)
395+
setSearchRequestId(reqId)
396+
setSearchLoading(true)
397+
vscode.postMessage({
398+
type: "searchFiles",
399+
query: unescapeSpaces(nextQuery),
400+
requestId: reqId,
401+
})
402+
403+
// Keep focus in the textarea for continued typing
404+
setTimeout(() => {
405+
textAreaRef.current?.focus()
406+
}, 0)
407+
408+
return
409+
}
410+
345411
setShowContextMenu(false)
346412
setSelectedType(null)
347413

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React from "react"
2+
import { render, fireEvent, screen } from "@src/utils/test-utils"
3+
import { useExtensionState } from "@src/context/ExtensionStateContext"
4+
import { vscode } from "@src/utils/vscode"
5+
import { ContextMenuOptionType } from "@src/utils/context-mentions"
6+
import { ChatTextArea } from "../ChatTextArea"
7+
8+
// Mock VS Code messaging
9+
vi.mock("@src/utils/vscode", () => ({
10+
vscode: {
11+
postMessage: vi.fn(),
12+
},
13+
}))
14+
15+
// Capture the last props passed to ContextMenu so we can invoke onSelect directly
16+
let lastContextMenuProps: any = null
17+
vi.mock("../ContextMenu", () => {
18+
return {
19+
__esModule: true,
20+
default: (props: any) => {
21+
lastContextMenuProps = props
22+
return <div data-testid="context-menu" />
23+
},
24+
__getLastProps: () => lastContextMenuProps,
25+
}
26+
})
27+
28+
// Mock ExtensionStateContext
29+
vi.mock("@src/context/ExtensionStateContext")
30+
31+
describe("ChatTextArea - folder drilldown behavior", () => {
32+
const defaultProps = {
33+
inputValue: "",
34+
setInputValue: vi.fn(),
35+
onSend: vi.fn(),
36+
sendingDisabled: false,
37+
selectApiConfigDisabled: false,
38+
onSelectImages: vi.fn(),
39+
shouldDisableImages: false,
40+
placeholderText: "Type a message...",
41+
selectedImages: [],
42+
setSelectedImages: vi.fn(),
43+
onHeightChange: vi.fn(),
44+
mode: "architect",
45+
setMode: vi.fn(),
46+
modeShortcutText: "(⌘. for next mode)",
47+
}
48+
49+
beforeEach(() => {
50+
vi.clearAllMocks()
51+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
52+
filePaths: ["src/", "src/index.ts"],
53+
openedTabs: [],
54+
taskHistory: [],
55+
cwd: "/test/workspace",
56+
})
57+
})
58+
59+
it("keeps picker open and triggers folder children search when selecting a folder", () => {
60+
const setInputValue = vi.fn()
61+
62+
const { container } = render(<ChatTextArea {...defaultProps} setInputValue={setInputValue} />)
63+
64+
// Type to open the @-context menu and set a query
65+
const textarea = container.querySelector("textarea")!
66+
fireEvent.change(textarea, {
67+
target: { value: "@s", selectionStart: 2 },
68+
})
69+
70+
// Ensure our mocked ContextMenu rendered and captured props
71+
expect(screen.getByTestId("context-menu")).toBeInTheDocument()
72+
const props = lastContextMenuProps
73+
expect(props).toBeTruthy()
74+
expect(typeof props.onSelect).toBe("function")
75+
76+
// Simulate selecting a concrete folder suggestion (e.g. "/src")
77+
props.onSelect(ContextMenuOptionType.Folder, "/src")
78+
79+
// The input should contain "@/src/" with NO trailing space and the picker should remain open
80+
expect(setInputValue).toHaveBeenCalled()
81+
const finalValue = setInputValue.mock.calls.at(-1)?.[0]
82+
expect(finalValue).toBe("@/src/")
83+
84+
// Context menu should still be present (picker remains open)
85+
expect(screen.getByTestId("context-menu")).toBeInTheDocument()
86+
87+
// It should have kicked off a searchFiles request for the folder children
88+
const pm = vscode.postMessage as ReturnType<typeof vi.fn>
89+
expect(pm).toHaveBeenCalled()
90+
const lastMsg = pm.mock.calls.at(-1)?.[0]
91+
expect(lastMsg).toMatchObject({ type: "searchFiles" })
92+
// Query mirrors substring after '@' including leading slash per existing logic
93+
expect(lastMsg.query).toBe("/src/")
94+
expect(typeof lastMsg.requestId).toBe("string")
95+
})
96+
})

0 commit comments

Comments
 (0)