diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 891ec9226..720132048 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()) { @@ -832,8 +838,8 @@ export default function SmartWorkspaceNameField({ return } let stale = false - setBranches([]) - setBranchResultsSource(null) + // Why: keep prior branch rows until this request settles; visibility already + // holds the last list while the user types ahead of the debounced query. setBranchesLoading(true) void searchRuntimeRepoBaseRefDetails( selectedRepoOwnerSettings, @@ -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, @@ -1081,7 +1108,7 @@ export default function SmartWorkspaceNameField({ } }, [rows]) - // Why: source rows lag debouncedQuery, so keep Enter off a stale row. + // Why: live input leads debounced search; freeze highlight until the query catches up. const valueWithinSourceLimit = isSmartWorkspaceSourceQueryWithinLimit(value) const debouncedQueryWithinSourceLimit = isSmartWorkspaceSourceQueryWithinLimit(debouncedQuery) const trimmedValue = valueWithinSourceLimit ? value.trim() : '' @@ -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] @@ -1144,10 +1179,15 @@ export default function SmartWorkspaceNameField({ ) ?? null const loading = githubLoading || gitlabLoading || branchesLoading || linearLoading - const ActiveInputIcon = mode === 'text' ? CaseSensitive : loading ? LoaderCircle : Search + // Why: only spin on first load — not on every in-flight refresh while rows stay visible. + const showSearchSpinner = loading && searchResultRows.length === 0 + const ActiveInputIcon = + mode === 'text' ? CaseSensitive : showSearchSpinner ? LoaderCircle : Search const handleSelect = useCallback( (row: RowEntry) => { + // Why: select what is shown — held provider rows stay visible while the + // query is ahead of debounce, so blocking them made click/Enter no-ops. if (row.kind === 'use-name' || row.kind === 'create-branch') { // Why: "create new branch" has no ref to base from, so it uses the typed-name path (default base). onValueChange(row.name) @@ -1413,7 +1453,14 @@ export default function SmartWorkspaceNameField({ > { + // Why: cmdk re-emits when the item list reshapes; ignore while the query + // lags so the highlight cannot thrash mid-typing. + if (isQueryStale) { + return + } + setCommandValue(next) + }} shouldFilter={false} className="overflow-visible bg-transparent" > @@ -1499,7 +1546,7 @@ export default function SmartWorkspaceNameField({ { expect( resolveSmartWorkspaceCommandValue({ currentValue: 'github-12', - rows: [row('use-name', 'use-name-fix'), row('github', 'github-12')], + rows: [row('use-name', 'use-name'), row('github', 'github-12')], isQueryStale: false, sourceIntent: null }) @@ -24,40 +24,51 @@ describe('resolveSmartWorkspaceCommandValue', () => { expect( resolveSmartWorkspaceCommandValue({ currentValue: 'github-12', - rows: [row('use-name', 'use-name-fix'), row('branch', 'branch-main')], + rows: [row('use-name', 'use-name'), row('branch', 'branch-main')], isQueryStale: false, sourceIntent: null }) - ).toBe('use-name-fix') + ).toBe('use-name') }) - it('uses typed-text rows while source results are stale', () => { + it('freezes the current arm while the query is ahead of debounced search', () => { expect( resolveSmartWorkspaceCommandValue({ currentValue: 'github-12', - rows: [row('use-name', 'use-name-fix'), row('github', 'github-12')], + rows: [row('use-name', 'use-name'), row('github', 'github-12')], isQueryStale: true, sourceIntent: null }) - ).toBe('use-name-fix') + ).toBe('github-12') }) - it('clears selection while stale source-only rows have no typed fallback', () => { + it('falls back to typed-text when a frozen arm is no longer rendered', () => { expect( resolveSmartWorkspaceCommandValue({ currentValue: 'github-12', - rows: [row('github', 'github-12')], + rows: [row('use-name', 'use-name'), row('github', 'github-99')], isQueryStale: true, sourceIntent: null }) - ).toBe('') + ).toBe('use-name') + }) + + it('falls back to the first provider row when stale with no typed-text', () => { + expect( + resolveSmartWorkspaceCommandValue({ + currentValue: 'github-12', + rows: [row('github', 'github-99')], + isQueryStale: true, + sourceIntent: null + }) + ).toBe('github-99') }) it('prefers matching source-intent rows once fresh results arrive', () => { expect( resolveSmartWorkspaceCommandValue({ - currentValue: 'use-name-123', - rows: [row('use-name', 'use-name-123'), row('github', 'github-123')], + currentValue: 'use-name', + rows: [row('use-name', 'use-name'), row('github', 'github-123')], isQueryStale: false, sourceIntent: 'github' }) @@ -65,8 +76,8 @@ describe('resolveSmartWorkspaceCommandValue', () => { expect( resolveSmartWorkspaceCommandValue({ - currentValue: 'use-name-gitlab-url', - rows: [row('use-name', 'use-name-gitlab-url'), row('gitlab', 'gitlab-123')], + currentValue: 'use-name', + rows: [row('use-name', 'use-name'), row('gitlab', 'gitlab-123')], isQueryStale: false, sourceIntent: 'gitlab' }) @@ -74,8 +85,8 @@ describe('resolveSmartWorkspaceCommandValue', () => { expect( resolveSmartWorkspaceCommandValue({ - currentValue: 'use-name-eng-123', - rows: [row('use-name', 'use-name-eng-123'), row('linear', 'linear-ENG-123')], + currentValue: 'use-name', + rows: [row('use-name', 'use-name'), row('linear', 'linear-ENG-123')], isQueryStale: false, sourceIntent: 'linear' }) 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 c3c2d152c..f51bc3f18 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', () => { @@ -102,7 +104,7 @@ describe('Branch source results', () => { expect(rows).toEqual([]) }) - it('hides branch results from a stale Branch-mode query', () => { + it('hides branch results after the input is cleared while a prior query is still held', () => { expect( getVisibleBranchResults({ mode: 'branches', @@ -115,6 +117,137 @@ describe('Branch source results', () => { ).toEqual([]) }) + it('keeps the last branch results while the user types ahead of the settled query', () => { + expect( + getVisibleBranchResults({ + mode: 'branches', + value: 'featu', + selectedRepoId: 'repo-1', + resultRepoId: 'repo-1', + resultQuery: 'feat', + branches: [{ refName: 'origin/feature', localBranchName: 'feature' }] + }) + ).toEqual([{ refName: 'origin/feature', localBranchName: 'feature' }]) + }) + + it('keeps the last branch results while the user trims a prefix of the settled query', () => { + expect( + getVisibleBranchResults({ + mode: 'branches', + value: 'fe', + selectedRepoId: 'repo-1', + resultRepoId: 'repo-1', + resultQuery: 'feat', + branches: [{ refName: 'origin/feature', localBranchName: 'feature' }] + }) + ).toEqual([{ refName: 'origin/feature', localBranchName: 'feature' }]) + }) + + it('hides held branch results when the live query diverges from the settled query', () => { + expect( + getVisibleBranchResults({ + mode: 'branches', + value: 'bug', + selectedRepoId: 'repo-1', + resultRepoId: 'repo-1', + resultQuery: 'feat', + branches: [{ refName: 'origin/feature', localBranchName: 'feature' }] + }) + ).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({ + mode: 'branches', + value: 'Feat', + selectedRepoId: 'repo-1', + resultRepoId: 'repo-1', + resultQuery: 'feat', + branches: [{ refName: 'origin/feature', localBranchName: 'feature' }] + }) + ).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({ + mode: 'smart', + value: 'refund-flow', + branches: [], + githubItems: [], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + resultLimit: 12 + })[0] + ).toMatchObject({ kind: 'use-name', value: 'use-name', name: 'refund-flow' }) + + expect( + buildSmartWorkspaceSourceRows({ + mode: 'branches', + value: 'new-branch', + branches: [], + githubItems: [], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + resultLimit: 12 + })[0] + ).toMatchObject({ kind: 'create-branch', value: 'create-branch', name: 'new-branch' }) + }) + it('keeps matching empty-query branch results visible in Branch mode', () => { expect( getVisibleBranchResults({ diff --git a/src/shared/new-workspace/smart-workspace-command-value.ts b/src/shared/new-workspace/smart-workspace-command-value.ts index 58d0e4289..b8f88e95a 100644 --- a/src/shared/new-workspace/smart-workspace-command-value.ts +++ b/src/shared/new-workspace/smart-workspace-command-value.ts @@ -28,9 +28,14 @@ export function resolveSmartWorkspaceCommandValue({ return currentValue } + // Why: freeze the arm while the live input is ahead of debounced search so the + // highlight does not thrash to use-name / empty / first-row on every keystroke. if (isQueryStale) { + if (rows.some((row) => row.value === currentValue)) { + return currentValue + } const typedTextRow = rows.find((row) => row.kind === 'use-name' || row.kind === 'create-branch') - return typedTextRow?.value ?? '' + return typedTextRow?.value ?? rows[0]?.value ?? '' } if (sourceIntent === 'github') { diff --git a/src/shared/new-workspace/smart-workspace-source-results.ts b/src/shared/new-workspace/smart-workspace-source-results.ts index 680419909..627c68283 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, @@ -93,16 +116,51 @@ export function getVisibleBranchResults({ if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { return [] } - const currentQuery = value.trim() if (mode !== 'branches' && mode !== 'smart') { return [] } - if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery !== currentQuery) { + if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery === null) { + return [] + } + const currentQuery = value.trim() + // Why: hold the last settled list while the user extends/trims the query so the + // dropdown does not blank between debounced keystrokes. Drop the hold when the + // query diverges (e.g. "feat" → "bug") so unrelated rows do not linger. + if (currentQuery === '') { + return resultQuery === '' ? branches : [] + } + 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, @@ -130,7 +188,8 @@ export function buildSmartWorkspaceSourceRows({ const trimmed = value.trim() const nextRows: SmartWorkspaceSourceRow[] = [] if (trimmed && mode === 'smart') { - nextRows.push({ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }) + // Why: stable cmdk value — embedding the query remounted the row every keystroke. + nextRows.push({ kind: 'use-name', value: 'use-name', name: trimmed }) } if (mode === 'text') { return nextRows @@ -159,7 +218,7 @@ export function buildSmartWorkspaceSourceRows({ (branch) => branch.refName === trimmed || branch.localBranchName === trimmed ) if (trimmed && mode === 'branches' && !branchExactMatch) { - nextRows.push({ kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }) + nextRows.push({ kind: 'create-branch', value: 'create-branch', name: trimmed }) } nextRows.push( ...branches.map((branch) => ({