mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user