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.
This commit is contained in:
Jinjing
2026-07-29 16:42:06 -07:00
parent 857f28554b
commit 6ae19764a9
5 changed files with 121 additions and 29 deletions
@@ -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({
>
<Command
value={resolvedCommandValue}
onValueChange={setCommandValue}
onValueChange={(next) => {
// 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({
<ActiveInputIcon
className={cn(
'pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground',
loading && mode !== 'text' && 'animate-spin'
showSearchSpinner && mode !== 'text' && 'animate-spin'
)}
/>
<Input
@@ -1588,12 +1609,19 @@ export default function SmartWorkspaceNameField({
}
if (open && rows.length > 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?.()
}
@@ -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'
})
@@ -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({
@@ -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') {
@@ -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) => ({