Fix autocomplete popover stability and workspace file suggestion ranking
This commit is contained in:
@@ -642,50 +642,54 @@ export function AgentInputArea({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Command autocomplete dropdown */}
|
|
||||||
{autocomplete.isVisible && (
|
|
||||||
<Autocomplete
|
|
||||||
options={autocomplete.options}
|
|
||||||
selectedIndex={autocomplete.selectedIndex}
|
|
||||||
isLoading={autocomplete.isLoading}
|
|
||||||
errorMessage={autocomplete.errorMessage}
|
|
||||||
loadingText={autocomplete.loadingText}
|
|
||||||
emptyText={autocomplete.emptyText}
|
|
||||||
onSelect={autocomplete.onSelectOption}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sendError && <Text style={styles.sendErrorText}>{sendError}</Text>}
|
{sendError && <Text style={styles.sendErrorText}>{sendError}</Text>}
|
||||||
|
|
||||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
<View style={styles.messageInputContainer}>
|
||||||
<MessageInput
|
{/* Command + file mention autocomplete rendered as a true popover */}
|
||||||
ref={messageInputRef}
|
{autocomplete.isVisible && (
|
||||||
value={userInput}
|
<View style={styles.autocompletePopover} pointerEvents="box-none">
|
||||||
onChangeText={setUserInput}
|
<Autocomplete
|
||||||
onSubmit={handleSubmit}
|
options={autocomplete.options}
|
||||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
selectedIndex={autocomplete.selectedIndex}
|
||||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
isLoading={autocomplete.isLoading}
|
||||||
images={selectedImages}
|
errorMessage={autocomplete.errorMessage}
|
||||||
onPickImages={handlePickImage}
|
loadingText={autocomplete.loadingText}
|
||||||
onAddImages={addImages}
|
emptyText={autocomplete.emptyText}
|
||||||
onRemoveImage={handleRemoveImage}
|
onSelect={autocomplete.onSelectOption}
|
||||||
client={client}
|
/>
|
||||||
placeholder="Message agent..."
|
</View>
|
||||||
autoFocus={autoFocus}
|
)}
|
||||||
disabled={isSubmitLoading}
|
|
||||||
isScreenFocused={isScreenFocused}
|
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||||
leftContent={leftContent}
|
<MessageInput
|
||||||
rightContent={rightContent}
|
ref={messageInputRef}
|
||||||
voiceServerId={serverId}
|
value={userInput}
|
||||||
voiceAgentId={agentId}
|
onChangeText={setUserInput}
|
||||||
isAgentRunning={isAgentRunning}
|
onSubmit={handleSubmit}
|
||||||
onQueue={handleQueue}
|
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||||
onKeyPress={handleCommandKeyPress}
|
images={selectedImages}
|
||||||
onSelectionChange={(selection) => {
|
onPickImages={handlePickImage}
|
||||||
setCursorIndex(selection.start)
|
onAddImages={addImages}
|
||||||
}}
|
onRemoveImage={handleRemoveImage}
|
||||||
/>
|
client={client}
|
||||||
|
placeholder="Message agent..."
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
disabled={isSubmitLoading}
|
||||||
|
isScreenFocused={isScreenFocused}
|
||||||
|
leftContent={leftContent}
|
||||||
|
rightContent={rightContent}
|
||||||
|
voiceServerId={serverId}
|
||||||
|
voiceAgentId={agentId}
|
||||||
|
isAgentRunning={isAgentRunning}
|
||||||
|
onQueue={handleQueue}
|
||||||
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
|
onKeyPress={handleCommandKeyPress}
|
||||||
|
onSelectionChange={(selection) => {
|
||||||
|
setCursorIndex(selection.start)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -709,7 +713,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
marginHorizontal: 'auto',
|
marginHorizontal: 'auto',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
overflow: 'hidden',
|
overflow: 'visible',
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
},
|
},
|
||||||
inputAreaContent: {
|
inputAreaContent: {
|
||||||
@@ -717,6 +721,18 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
messageInputContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
autocompletePopover: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: '100%',
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
zIndex: 30,
|
||||||
|
},
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
width: 34,
|
width: 34,
|
||||||
height: 34,
|
height: 34,
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ interface AutocompleteProps {
|
|||||||
maxHeight?: number
|
maxHeight?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g
|
||||||
|
|
||||||
|
function removeBoltGlyphs(value?: string): string | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const cleaned = value.replace(BOLT_GLYPH_PATTERN, '').trim()
|
||||||
|
return cleaned.length > 0 ? cleaned : undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function Autocomplete({
|
export function Autocomplete({
|
||||||
options,
|
options,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
@@ -156,6 +166,9 @@ export function Autocomplete({
|
|||||||
>
|
>
|
||||||
{options.map((option, index) => {
|
{options.map((option, index) => {
|
||||||
const isSelected = index === selectedIndex
|
const isSelected = index === selectedIndex
|
||||||
|
const optionLabel = removeBoltGlyphs(option.label) ?? option.label
|
||||||
|
const optionDetail = removeBoltGlyphs(option.detail)
|
||||||
|
const optionDescription = removeBoltGlyphs(option.description)
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={option.id}
|
key={option.id}
|
||||||
@@ -177,12 +190,12 @@ export function Autocomplete({
|
|||||||
) : null}
|
) : null}
|
||||||
<View style={styles.itemMain}>
|
<View style={styles.itemMain}>
|
||||||
<View style={styles.itemHeader}>
|
<View style={styles.itemHeader}>
|
||||||
<Text style={styles.itemLabel}>{option.label}</Text>
|
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
||||||
{option.detail ? <Text style={styles.itemDetail}>{option.detail}</Text> : null}
|
{optionDetail ? <Text style={styles.itemDetail}>{optionDetail}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
{option.description ? (
|
{optionDescription ? (
|
||||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||||
{option.description}
|
{optionDescription}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -237,7 +250,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
itemLabel: {
|
itemLabel: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
itemDetail: {
|
itemDetail: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
import type { AutocompleteOption } from '@/components/ui/autocomplete'
|
import type { AutocompleteOption } from '@/components/ui/autocomplete'
|
||||||
import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query'
|
import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query'
|
||||||
import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
|
import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
|
||||||
@@ -192,6 +192,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
isConnected,
|
isConnected,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
|
|
||||||
const options = useMemo<AgentAutocompleteOption[]>(() => {
|
const options = useMemo<AgentAutocompleteOption[]>(() => {
|
||||||
@@ -214,11 +215,11 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'file' && activeFileMention) {
|
if (mode === 'file' && activeFileMention) {
|
||||||
return (fileSuggestionsQuery.data ?? []).map((entry) => ({
|
const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? [])
|
||||||
|
return orderedEntries.map((entry) => ({
|
||||||
type: 'workspace_entry' as const,
|
type: 'workspace_entry' as const,
|
||||||
id: `${entry.kind}:${entry.path}`,
|
id: `${entry.kind}:${entry.path}`,
|
||||||
label: entry.path,
|
label: entry.path,
|
||||||
detail: entry.kind === 'directory' ? 'Directory' : 'File',
|
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
entryPath: entry.path,
|
entryPath: entry.path,
|
||||||
mention: activeFileMention,
|
mention: activeFileMention,
|
||||||
@@ -260,7 +261,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
mode === 'command'
|
mode === 'command'
|
||||||
? isCommandsLoading
|
? isCommandsLoading
|
||||||
: mode === 'file'
|
: mode === 'file'
|
||||||
? fileSuggestionsQuery.isPending || fileSuggestionsQuery.isLoading
|
? fileSuggestionsQuery.isPending || (fileSuggestionsQuery.isLoading && options.length === 0)
|
||||||
: false
|
: false
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
mode === 'command'
|
mode === 'command'
|
||||||
|
|||||||
@@ -234,4 +234,33 @@ describe('searchWorkspaceEntries', () => {
|
|||||||
})
|
})
|
||||||
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
|
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('ignores node_modules entries so deep workspace files still resolve under scan limits', async () => {
|
||||||
|
mkdirSync(path.join(workspaceDir, 'packages', 'app', 'src', 'app'), { recursive: true })
|
||||||
|
writeFileSync(path.join(workspaceDir, 'packages', 'app', 'src', 'app', '_layout.tsx'), '')
|
||||||
|
|
||||||
|
for (let index = 0; index < 120; index += 1) {
|
||||||
|
mkdirSync(path.join(workspaceDir, 'node_modules', `pkg-${index}`), { recursive: true })
|
||||||
|
writeFileSync(
|
||||||
|
path.join(workspaceDir, 'node_modules', `pkg-${index}`, 'index.js'),
|
||||||
|
'module.exports = {};\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
writeFileSync(path.join(workspaceDir, 'node_modules', '_layout.tsx'), '')
|
||||||
|
|
||||||
|
const results = await searchWorkspaceEntries({
|
||||||
|
cwd: workspaceDir,
|
||||||
|
query: '_layout.tsx',
|
||||||
|
limit: 20,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: true,
|
||||||
|
maxEntriesScanned: 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results).toContainEqual({
|
||||||
|
path: 'packages/app/src/app/_layout.tsx',
|
||||||
|
kind: 'file',
|
||||||
|
})
|
||||||
|
expect(results.some((entry) => entry.path.startsWith('node_modules/'))).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const directoryListCache = new Map<string, DirectoryListCacheEntry>()
|
|||||||
const workspaceEntryListCache = new Map<string, WorkspaceEntryListCacheEntry>()
|
const workspaceEntryListCache = new Map<string, WorkspaceEntryListCacheEntry>()
|
||||||
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER
|
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER
|
||||||
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER
|
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER
|
||||||
|
const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set(['node_modules'])
|
||||||
|
|
||||||
export async function searchHomeDirectories(
|
export async function searchHomeDirectories(
|
||||||
options: SearchHomeDirectoriesOptions
|
options: SearchHomeDirectoriesOptions
|
||||||
@@ -750,6 +751,9 @@ async function listWorkspaceChildEntries(input: {
|
|||||||
if (isHiddenDirectoryName(dirent.name)) {
|
if (isHiddenDirectoryName(dirent.name)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (isIgnoredWorkspaceDirectoryName(dirent.name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const candidatePath = path.join(input.directory, dirent.name)
|
const candidatePath = path.join(input.directory, dirent.name)
|
||||||
const entry = await resolveWorkspaceCandidate({
|
const entry = await resolveWorkspaceCandidate({
|
||||||
@@ -839,6 +843,10 @@ function isHiddenDirectoryName(name: string): boolean {
|
|||||||
return name.startsWith('.')
|
return name.startsWith('.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIgnoredWorkspaceDirectoryName(name: string): boolean {
|
||||||
|
return WORKSPACE_IGNORED_DIRECTORY_NAMES.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
||||||
directoryListCache.set(cacheKey, entry)
|
directoryListCache.set(cacheKey, entry)
|
||||||
pruneDirectoryListCache()
|
pruneDirectoryListCache()
|
||||||
|
|||||||
Reference in New Issue
Block a user