diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 5a1ad4258c3..72013204831 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -57,6 +57,7 @@ import { getBranchSearchRequest, getSmartWorkspaceEmptyHint, getVisibleBranchResults, + getVisibleHeldProviderResults, isSmartWorkspaceSourceQueryWithinLimit, type SmartNameMode, type SmartWorkspaceSourceRow @@ -558,6 +559,11 @@ export default function SmartWorkspaceNameField({ return } let stale = false + // Why: empty-query search must not briefly paint the previous non-empty result set + // once debounce catches a cleared field. + if (debouncedQuery.trim() === '') { + setGithubItems([]) + } const directNumber = normalizedGhQuery.directNumber const directLink = parsedGhLink if (directLink !== null && handledCrossRepoUrlRef.current !== debouncedQuery.trim()) { @@ -875,6 +881,10 @@ export default function SmartWorkspaceNameField({ let stale = false setLinearLoading(true) const trimmed = debouncedQuery.trim() + // Why: empty-query list must not briefly paint the previous non-empty result set. + if (trimmed === '') { + setLinearIssues([]) + } const request = trimmed ? searchLinearIssues(trimmed, RESULT_LIMIT, { sourceContext: linearSourceContext }) : listLinearIssues( @@ -992,6 +1002,10 @@ export default function SmartWorkspaceNameField({ setGitlabLoading(true) // Why: thread the typed query so the GitLab API filters MRs by name/number (shouldQueryGitlab already gates oversized queries). const trimmedQuery = debouncedQuery.trim() || undefined + // Why: empty-query list must not briefly paint the previous non-empty result set. + if (trimmedQuery === undefined) { + setGitlabItems([]) + } void Promise.all( repoBackedSearchTargets.map((target) => listGitLabMRsForSource({ @@ -1051,11 +1065,23 @@ export default function SmartWorkspaceNameField({ selectedRepoId: selectedRepo?.id ?? null, value }), - githubItems, + githubItems: getVisibleHeldProviderResults({ + items: githubItems, + value, + debouncedQuery + }), gitlabAvailable: gitlabSourceAvailable, - gitlabItems, + gitlabItems: getVisibleHeldProviderResults({ + items: gitlabItems, + value, + debouncedQuery + }), linearAvailable, - linearIssues, + linearIssues: getVisibleHeldProviderResults({ + items: linearIssues, + value, + debouncedQuery + }), mode, resultLimit: RESULT_LIMIT, value @@ -1063,6 +1089,7 @@ export default function SmartWorkspaceNameField({ [ branches, branchResultsSource, + debouncedQuery, githubItems, gitlabSourceAvailable, gitlabItems, @@ -1115,6 +1142,14 @@ export default function SmartWorkspaceNameField({ isQueryStale, sourceIntent }) + // Why: while isQueryStale, cmdk onValueChange is ignored; re-sync the stored arm + // when the query settles so commandValue cannot lag resolvedCommandValue. + useEffect(() => { + if (isQueryStale || commandValue === resolvedCommandValue) { + return + } + setCommandValue(resolvedCommandValue) + }, [commandValue, isQueryStale, resolvedCommandValue]) const activeEmojiShortcode = useMemo( () => getActiveWorkspaceEmojiShortcode(value, emojiCursor), [emojiCursor, value] diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts index 1a3709686f0..f51bc3f1818 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts @@ -5,7 +5,9 @@ import { getBranchSearchRequest, getSmartWorkspaceEmptyHint, getVisibleBranchResults, - isSmartWorkspaceSourceQueryWithinLimit + getVisibleHeldProviderResults, + isSmartWorkspaceSourceQueryWithinLimit, + shouldHoldSourceResultsForQuery } from './smart-workspace-source-results' describe('Branch source results', () => { @@ -154,6 +156,25 @@ describe('Branch source results', () => { ).toEqual([]) }) + it('drops a short settled query once the live query grows far beyond a typing delta', () => { + // Why: prefix-only hold would keep "f" results under "fix-unrelated-payment-bug". + expect( + getVisibleBranchResults({ + mode: 'branches', + value: 'fix-unrelated-payment-bug', + selectedRepoId: 'repo-1', + resultRepoId: 'repo-1', + resultQuery: 'f', + branches: [{ refName: 'origin/foo', localBranchName: 'foo' }] + }) + ).toEqual([]) + expect( + shouldHoldSourceResultsForQuery({ resultQuery: 'f', value: 'fix-unrelated-payment-bug' }) + ).toBe(false) + expect(shouldHoldSourceResultsForQuery({ resultQuery: 'feat', value: 'featu' })).toBe(true) + expect(shouldHoldSourceResultsForQuery({ resultQuery: 'feat', value: 'feature/x' })).toBe(false) + }) + it('keeps held branch results across case-only edits of a prefix query', () => { expect( getVisibleBranchResults({ @@ -167,6 +188,36 @@ describe('Branch source results', () => { ).toEqual([{ refName: 'origin/feature', localBranchName: 'feature' }]) }) + it('hides held provider results immediately when the field is cleared ahead of debounce', () => { + expect( + getVisibleHeldProviderResults({ + items: [{ id: 'pr-1' }], + value: '', + debouncedQuery: 'fix' + }) + ).toEqual([]) + }) + + it('keeps held provider results while the user types ahead of debounce', () => { + expect( + getVisibleHeldProviderResults({ + items: [{ id: 'pr-1' }], + value: 'fix', + debouncedQuery: 'fi' + }) + ).toEqual([{ id: 'pr-1' }]) + }) + + it('shows provider results once the cleared field and debounce are both empty', () => { + expect( + getVisibleHeldProviderResults({ + items: [{ id: 'default-1' }], + value: '', + debouncedQuery: '' + }) + ).toEqual([{ id: 'default-1' }]) + }) + it('uses stable cmdk values for typed-text actions', () => { expect( buildSmartWorkspaceSourceRows({ diff --git a/src/shared/new-workspace/smart-workspace-source-results.ts b/src/shared/new-workspace/smart-workspace-source-results.ts index 6e6d30c8859..627c68283bf 100644 --- a/src/shared/new-workspace/smart-workspace-source-results.ts +++ b/src/shared/new-workspace/smart-workspace-source-results.ts @@ -75,6 +75,29 @@ export function getBranchSearchRequest({ return { repoId: selectedRepoId, query: trimmedQuery, limit } } +/** + * Why: provider arrays lag the live input (200ms debounce). Keep them while the + * user is still typing, but hide immediately when the field is cleared so prior + * non-empty results cannot stay selectable until debounce catches up. + */ +export function getVisibleHeldProviderResults({ + items, + value, + debouncedQuery +}: { + items: readonly T[] + value: string + debouncedQuery: string +}): T[] { + if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { + return [] + } + if (value.trim() === '' && debouncedQuery.trim() !== '') { + return [] + } + return items.slice() +} + export function getVisibleBranchResults({ branches, mode, @@ -106,18 +129,38 @@ export function getVisibleBranchResults({ if (currentQuery === '') { return resultQuery === '' ? branches : [] } - const currentQueryKey = currentQuery.toLowerCase() - const resultQueryKey = resultQuery.toLowerCase() - if ( - resultQueryKey !== currentQueryKey && - !currentQueryKey.startsWith(resultQueryKey) && - !resultQueryKey.startsWith(currentQueryKey) - ) { + if (!shouldHoldSourceResultsForQuery({ resultQuery, value: currentQuery })) { return [] } return branches } +/** Max |live − settled| length while still treating a prefix as "still typing". */ +const SOURCE_RESULT_HOLD_MAX_DELTA = 4 + +/** + * Why: prefix-only hold lets a settled "f" stick under "fix-unrelated-…" for the + * whole next debounce. Cap the length delta so hold covers fast typing, not long + * continuations of a short settled query. + */ +export function shouldHoldSourceResultsForQuery({ + resultQuery, + value +}: { + resultQuery: string + value: string +}): boolean { + const currentQueryKey = value.trim().toLowerCase() + const resultQueryKey = resultQuery.trim().toLowerCase() + if (resultQueryKey === currentQueryKey) { + return true + } + if (!currentQueryKey.startsWith(resultQueryKey) && !resultQueryKey.startsWith(currentQueryKey)) { + return false + } + return Math.abs(currentQueryKey.length - resultQueryKey.length) <= SOURCE_RESULT_HOLD_MAX_DELTA +} + export function buildSmartWorkspaceSourceRows({ branches, githubItems,