From 6ae19764a9a85596dae92851d31e1ceb2149bd8e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:42:06 -0700 Subject: [PATCH] fix(new-workspace): stop UI flashing when typing ahead of search Hold branch results while queries settle, show the spinner only on initial load, use stable cmdk values, and guard selections against stale rows. This prevents the highlight from jumping around when typing faster than the debounced search settles. --- .../new-workspace/SmartWorkspaceNameField.tsx | 44 ++++++++++++++---- .../smart-workspace-command-value.test.ts | 41 ++++++++++------- .../smart-workspace-source-results.test.ts | 45 ++++++++++++++++++- .../smart-workspace-command-value.ts | 7 ++- .../smart-workspace-source-results.ts | 13 ++++-- 5 files changed, 121 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 891ec922682..4d65c87bd47 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -832,8 +832,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, @@ -1144,10 +1144,17 @@ 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: provider rows can lag the live input; typed-name / create-branch stay safe. + if (isQueryStale && row.kind !== 'use-name' && row.kind !== 'create-branch') { + return + } 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) @@ -1163,7 +1170,14 @@ export default function SmartWorkspaceNameField({ } setOpen(false) }, - [onBranchSelect, onGitHubItemSelect, onGitLabItemSelect, onLinearIssueSelect, onValueChange] + [ + isQueryStale, + onBranchSelect, + onGitHubItemSelect, + onGitLabItemSelect, + onLinearIssueSelect, + onValueChange + ] ) const applyEmojiReplacement = useCallback( @@ -1413,7 +1427,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 +1520,7 @@ export default function SmartWorkspaceNameField({ 0) { const row = rows.find((entry) => entry.value === resolvedCommandValue) - if (row) { + if ( + row && + !( + isQueryStale && + row.kind !== 'use-name' && + row.kind !== 'create-branch' + ) + ) { event.preventDefault() handleSelect(row) return } - // No highlighted row (e.g. cleared stale GitHub/Linear results); fall through to onPlainEnter so the keypress isn't inert. + // No selectable row (stale provider arm); fall through to onPlainEnter. } onPlainEnter?.() } diff --git a/src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts index 329c61c526c..4d030b29803 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts @@ -13,7 +13,7 @@ describe('resolveSmartWorkspaceCommandValue', () => { 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 c3c2d152cb4..8fd9744ded2 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 @@ -102,7 +102,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 +115,49 @@ 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('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 58d0e4289d3..b8f88e95a8a 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 68041990910..127573ddd63 100644 --- a/src/shared/new-workspace/smart-workspace-source-results.ts +++ b/src/shared/new-workspace/smart-workspace-source-results.ts @@ -93,11 +93,15 @@ 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 [] + } + // Why: hold the last settled branch list while the user types ahead — blanking + // on query mismatch flashed the dropdown empty between keystrokes. + if (value.trim() === '' && resultQuery !== '') { return [] } return branches @@ -130,7 +134,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 +164,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) => ({