Focus workspace name field initially in new workspace dialog (#5456)

- Shifts the initial autofocus from the project combobox to the smart
  name input or pre-filled source pill so users can start typing
  immediately.
- Pins the typed text action row (e.g., "Use name...") to the top of
  the search results dropdown with a sticky header.
- Accepts an external GitHub source context override in the smart name
  field to improve contextual suggestions.
- Updates unit and E2E tests to verify the new focus target behavior.
This commit is contained in:
Jinjing
2026-06-16 00:19:42 -07:00
committed by GitHub
parent 4481e2fabf
commit 2740cba2bf
7 changed files with 133 additions and 36 deletions
@@ -42,6 +42,7 @@ import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-opti
import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options'
import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
type RepoOption = React.ComponentProps<typeof RepoCombobox>['repos'][number]
@@ -79,6 +80,7 @@ type NewWorkspaceComposerCardProps = {
onSmartLinearIssueSelect: (issue: LinearIssue) => void
smartNameSelection: SmartWorkspaceNameSelection | null
onClearSmartNameSelection: () => void
smartNameGitHubSourceContext?: TaskSourceContext | null
/** Advisory shown under the name field when a fork PR can't accept maintainer pushes. */
forkPushWarning: string | null
detectedAgentIds: Set<TuiAgent> | null
@@ -307,6 +309,7 @@ export default function NewWorkspaceComposerCard({
onSmartLinearIssueSelect,
smartNameSelection,
onClearSmartNameSelection,
smartNameGitHubSourceContext,
forkPushWarning,
detectedAgentIds,
onOpenAgentSettings,
@@ -518,12 +521,10 @@ export default function NewWorkspaceComposerCard({
projectPlaceholder ??
translate('auto.components.NewWorkspaceComposerCard.dccd26d4e4', 'Choose project')
}
// Why: programmatic .focus() from the Dialog's onOpenAutoFocus
// handler does not reliably trigger :focus-visible in Chromium.
// Mirror the Input component's standard ring (border-ring +
// ring-ring/50, 3px) onto :focus so the autofocused repo trigger
// paints the familiar field ring instead of leaving no visible
// focus state.
// Why: programmatic .focus() does not reliably trigger
// :focus-visible in Chromium. Mirror the Input component's
// standard ring (border-ring + ring-ring/50, 3px) onto :focus so
// keyboard navigation paints the familiar field ring.
triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50"
invalid={Boolean(projectError)}
describedBy={projectDescriptionId}
@@ -615,6 +616,7 @@ export default function NewWorkspaceComposerCard({
onLinearIssueSelect={onSmartLinearIssueSelect}
selectedSource={smartNameSelection}
onClearSelectedSource={onClearSmartNameSelection}
githubSourceContext={smartNameGitHubSourceContext}
disabled={selectedRepoRequiresConnection}
disabledPlaceholder="Connect this repo first"
textOnly={!selectedRepoIsGit}
@@ -88,8 +88,8 @@ function ComposerModalBody({
onOpenAutoFocus={(event) => {
// Why: Radix's FocusScope fires this once the dialog has mounted.
// preventDefault stops it from focusing whatever first-tabbable it
// picks (close button), and we instead focus the repo picker so the
// keyboard flow starts at the top of the unified create form.
// picks (close button), and we instead focus the name/source field
// so users can start typing immediately.
event.preventDefault()
const content = event.currentTarget as HTMLElement
getWorkspaceComposerInitialFocusTarget(content)?.focus({ preventScroll: true })
@@ -71,7 +71,10 @@ import {
getSmartWorkspaceNameModes,
type MrStateFilter
} from './smart-workspace-localized-options'
import { buildTaskSourceContextFromRepo } from '../../../../shared/task-source-context'
import {
buildTaskSourceContextFromRepo,
type TaskSourceContext
} from '../../../../shared/task-source-context'
type RepoOption = ReturnType<typeof useAppStore.getState>['repos'][number]
@@ -89,6 +92,7 @@ type SmartWorkspaceNameFieldProps = {
onLinearIssueSelect: (issue: LinearIssue) => void
selectedSource: SmartWorkspaceNameSelection | null
onClearSelectedSource: () => void
githubSourceContext?: TaskSourceContext | null
inputRef?: React.RefObject<HTMLInputElement | null>
onPlainEnter?: () => void
disabled?: boolean
@@ -108,6 +112,19 @@ const RESULT_LIMIT = 12
type RowEntry = SmartWorkspaceSourceRow
const ROW_ITEM_CLASS_NAME = 'gap-2 px-3 py-2 text-xs'
function isTypedTextSourceRow(row: RowEntry): boolean {
return row.kind === 'use-name' || row.kind === 'create-branch'
}
function getRowItemClassName(row: RowEntry, options?: { pinnedAction?: boolean }): string {
return cn(
ROW_ITEM_CLASS_NAME,
options?.pinnedAction && isTypedTextSourceRow(row) && 'bg-muted/35'
)
}
export default function SmartWorkspaceNameField({
repos,
repoId,
@@ -120,6 +137,7 @@ export default function SmartWorkspaceNameField({
onLinearIssueSelect,
selectedSource,
onClearSelectedSource,
githubSourceContext: githubSourceContextOverride,
inputRef,
onPlainEnter,
disabled = false,
@@ -171,17 +189,18 @@ export default function SmartWorkspaceNameField({
() => getRepoOwnerRoutedSettings(settings, selectedRepo),
[selectedRepo, settings]
)
const githubSourceContext = useMemo(
() =>
selectedRepo
? buildTaskSourceContextFromRepo({
provider: 'github',
projectId: selectedRepo.id,
repo: selectedRepo
})
: null,
[selectedRepo]
)
const githubSourceContext = useMemo(() => {
if (githubSourceContextOverride?.provider === 'github') {
return githubSourceContextOverride
}
return selectedRepo
? buildTaskSourceContextFromRepo({
provider: 'github',
projectId: selectedRepo.id,
repo: selectedRepo
})
: null
}, [githubSourceContextOverride, selectedRepo])
const gitlabSourceContext = useMemo(
() =>
selectedRepo
@@ -797,6 +816,13 @@ export default function SmartWorkspaceNameField({
value
]
)
const { typedTextActionRow, searchResultRows } = useMemo(() => {
const typedTextRow = rows.find(isTypedTextSourceRow) ?? null
return {
typedTextActionRow: typedTextRow,
searchResultRows: typedTextRow ? rows.filter((row) => row !== typedTextRow) : rows
}
}, [rows])
// Why: source rows (GitHub/branches/Linear) are driven by debouncedQuery,
// so they're stale until the user pauses typing for SEARCH_DEBOUNCE_MS.
@@ -1020,6 +1046,7 @@ export default function SmartWorkspaceNameField({
// dialog wider than its max-w.
<div
ref={setSelectedSourceNode}
data-workspace-source-pill="true"
tabIndex={0}
onKeyDown={(event) => {
if (
@@ -1100,6 +1127,7 @@ export default function SmartWorkspaceNameField({
/>
<Input
ref={setInputNode}
data-workspace-name-input="true"
value={value}
onChange={(event) => {
onValueChange(event.target.value)
@@ -1210,13 +1238,29 @@ export default function SmartWorkspaceNameField({
</div>
) : null}
<CommandList className="!max-h-none min-h-0 flex-1 scrollbar-sleek">
{loading && rows.length === 0 ? (
{typedTextActionRow ? (
<div
className="sticky top-0 z-10 border-b border-border/40 bg-popover p-1"
onMouseDown={(event) => event.preventDefault()}
>
<CommandItem
key={typedTextActionRow.value}
value={typedTextActionRow.value}
onSelect={() => handleSelect(typedTextActionRow)}
className={getRowItemClassName(typedTextActionRow, { pinnedAction: true })}
>
<RowIcon row={typedTextActionRow} />
<RowLabel row={typedTextActionRow} />
</CommandItem>
</div>
) : null}
{loading && searchResultRows.length === 0 ? (
<div className="space-y-1 p-1">
{[0, 1, 2].map((index) => (
<div key={index} className="h-8 animate-pulse rounded bg-muted/40" />
))}
</div>
) : rows.length === 0 ? (
) : searchResultRows.length === 0 && !typedTextActionRow ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{mode === 'linear' && linearStatusChecked && !linearStatus.connected
? translate(
@@ -1225,21 +1269,21 @@ export default function SmartWorkspaceNameField({
)
: getSmartWorkspaceEmptyHint(mode)}
</div>
) : (
) : searchResultRows.length > 0 ? (
<CommandGroup className="p-1">
{rows.map((row) => (
{searchResultRows.map((row) => (
<CommandItem
key={row.value}
value={row.value}
onSelect={() => handleSelect(row)}
className="gap-2 px-2 py-1.5 text-xs"
className={getRowItemClassName(row)}
>
<RowIcon row={row} />
<RowLabel row={row} />
</CommandItem>
))}
</CommandGroup>
)}
) : null}
</CommandList>
</PopoverContent>
</Command>
@@ -1367,7 +1411,7 @@ function RowLabel({ row }: { row: RowEntry }): React.JSX.Element {
if (row.kind === 'use-name') {
return (
<span className="min-w-0 truncate">
{translate('auto.components.new.workspace.SmartWorkspaceNameField.b1a7d679ba', 'Use')}
{translate('auto.components.new.workspace.SmartWorkspaceNameField.b1a7d679ba', 'Use')}{' '}
<span className="font-medium text-foreground">
{translate('auto.components.new.workspace.SmartWorkspaceNameField.34ca97bce3', '"')}
{row.name}
@@ -188,6 +188,7 @@ export type ComposerCardProps = {
onSmartGitLabItemSelect: (item: GitLabWorkItem) => void
onSmartBranchSelect: (refName: string, localBranchName: string) => void
onSmartLinearIssueSelect: (issue: LinearIssue) => void
smartNameGitHubSourceContext?: TaskSourceContext | null
/** GitLab parallel of onBaseBranchPrSelect. */
onBaseBranchMrSelect?: (
baseBranch: string,
@@ -2846,6 +2847,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
onSmartGitLabItemSelect: handleSmartGitLabItemSelect,
onSmartBranchSelect: handleSmartBranchSelect,
onSmartLinearIssueSelect: handleSmartLinearIssueSelect,
smartNameGitHubSourceContext: selectedRepoGitHubSourceContext,
smartNameSelection,
onClearSmartNameSelection: handleClearSmartNameSelection,
agentPrompt,
@@ -4,7 +4,50 @@ import { describe, expect, it } from 'vitest'
import { getWorkspaceComposerInitialFocusTarget } from './workspace-composer-initial-focus'
describe('getWorkspaceComposerInitialFocusTarget', () => {
it('focuses the project combobox used by the current workspace composer', () => {
it('focuses the workspace name input used by the current composer', () => {
const root = document.createElement('div')
const nameInput = document.createElement('input')
nameInput.setAttribute('data-workspace-name-input', 'true')
root.append(nameInput)
expect(getWorkspaceComposerInitialFocusTarget(root)).toBe(nameInput)
})
it('prefers the name input when both name and project triggers exist', () => {
const root = document.createElement('div')
root.innerHTML = `
<button role="combobox" data-project-combobox-root="true"></button>
<input data-workspace-name-input="true" />
`
expect(getWorkspaceComposerInitialFocusTarget(root)).toBe(
root.querySelector('[data-workspace-name-input="true"]')
)
})
it('focuses the source pill when the name input is replaced by a selection', () => {
const root = document.createElement('div')
const pill = document.createElement('div')
pill.setAttribute('data-workspace-source-pill', 'true')
pill.setAttribute('tabindex', '0')
root.append(pill)
expect(getWorkspaceComposerInitialFocusTarget(root)).toBe(pill)
})
it('prefers the source pill over the project combobox when both exist', () => {
const root = document.createElement('div')
root.innerHTML = `
<button role="combobox" data-project-combobox-root="true"></button>
<div data-workspace-source-pill="true" tabindex="0"></div>
`
expect(getWorkspaceComposerInitialFocusTarget(root)).toBe(
root.querySelector('[data-workspace-source-pill="true"]')
)
})
it('falls back to the project combobox when the name input is absent', () => {
const root = document.createElement('div')
const projectTrigger = document.createElement('button')
projectTrigger.setAttribute('role', 'combobox')
@@ -14,7 +57,7 @@ describe('getWorkspaceComposerInitialFocusTarget', () => {
expect(getWorkspaceComposerInitialFocusTarget(root)).toBe(projectTrigger)
})
it('prefers project focus when both current and legacy triggers exist', () => {
it('prefers project focus over legacy repo trigger when the name input is absent', () => {
const root = document.createElement('div')
root.innerHTML = `
<button role="combobox" data-repo-combobox-root="true"></button>
@@ -1,10 +1,16 @@
const WORKSPACE_NAME_INPUT_SELECTOR = '[data-workspace-name-input="true"]'
const WORKSPACE_SOURCE_PILL_SELECTOR = '[data-workspace-source-pill="true"]'
const PROJECT_COMBOBOX_TRIGGER_SELECTOR = '[data-project-combobox-root="true"][role="combobox"]'
const LEGACY_REPO_COMBOBOX_TRIGGER_SELECTOR = '[data-repo-combobox-root="true"][role="combobox"]'
export function getWorkspaceComposerInitialFocusTarget(root: ParentNode): HTMLElement | null {
// Why: the composer moved from repo-first to project-first in the multi-host
// workbench; keep the old marker as a fallback for older/alternate surfaces.
// Why: most opens already have a project selected; land on the name/source
// field so users can type or press Enter immediately. The source pill
// replaces the input when a linked item or branch is pre-filled. Keep
// combobox fallbacks for surfaces that omit the smart name field.
return (
root.querySelector<HTMLElement>(WORKSPACE_NAME_INPUT_SELECTOR) ??
root.querySelector<HTMLElement>(WORKSPACE_SOURCE_PILL_SELECTOR) ??
root.querySelector<HTMLElement>(PROJECT_COMBOBOX_TRIGGER_SELECTOR) ??
root.querySelector<HTMLElement>(LEGACY_REPO_COMBOBOX_TRIGGER_SELECTOR)
)
+5 -5
View File
@@ -65,10 +65,10 @@ test.describe('Create Workspace', () => {
await expect(dialog).toBeVisible()
// Wait for the composer to settle. The card fires several async effects
// on mount (detected-agent probe, repo combobox autofocus + hydration,
// on mount (detected-agent probe, name-field autofocus + hydration,
// setup-hooks fetch). Clicking before those settle can race Radix's
// FocusScope reparenting.
await expect(dialog.getByRole('combobox').first()).toBeVisible()
await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible()
// Force the `getBaseRefDefault` IPC to round-trip so any consumer that
// renders the envelope (e.g. SourceControl) has a chance to crash
@@ -180,7 +180,7 @@ test.describe('Create Workspace', () => {
const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('combobox').first()).toBeVisible()
await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible()
const nameInput = dialog.getByPlaceholder(/Type a name/i)
await expect(nameInput).toBeVisible()
@@ -227,7 +227,7 @@ test.describe('Create Workspace', () => {
const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('combobox').first()).toBeVisible()
await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible()
await electronApp.evaluate(
({ ipcMain }, { title, url }) => {
@@ -337,7 +337,7 @@ test.describe('Create Workspace', () => {
const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('combobox').first()).toBeVisible()
await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible()
await electronApp.evaluate(
({ ipcMain }, { title, url }) => {