diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 66c9c5d35b1..e5c106d901b 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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['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 | 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} diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 65037331275..e0745a8feb2 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -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 }) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 51740a51062..d8211b4e87d 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -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['repos'][number] @@ -89,6 +92,7 @@ type SmartWorkspaceNameFieldProps = { onLinearIssueSelect: (issue: LinearIssue) => void selectedSource: SmartWorkspaceNameSelection | null onClearSelectedSource: () => void + githubSourceContext?: TaskSourceContext | null inputRef?: React.RefObject 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.
{ if ( @@ -1100,6 +1127,7 @@ export default function SmartWorkspaceNameField({ /> { onValueChange(event.target.value) @@ -1210,13 +1238,29 @@ export default function SmartWorkspaceNameField({
) : null} - {loading && rows.length === 0 ? ( + {typedTextActionRow ? ( +
event.preventDefault()} + > + handleSelect(typedTextActionRow)} + className={getRowItemClassName(typedTextActionRow, { pinnedAction: true })} + > + + + +
+ ) : null} + {loading && searchResultRows.length === 0 ? (
{[0, 1, 2].map((index) => (
))}
- ) : rows.length === 0 ? ( + ) : searchResultRows.length === 0 && !typedTextActionRow ? (
{mode === 'linear' && linearStatusChecked && !linearStatus.connected ? translate( @@ -1225,21 +1269,21 @@ export default function SmartWorkspaceNameField({ ) : getSmartWorkspaceEmptyHint(mode)}
- ) : ( + ) : searchResultRows.length > 0 ? ( - {rows.map((row) => ( + {searchResultRows.map((row) => ( handleSelect(row)} - className="gap-2 px-2 py-1.5 text-xs" + className={getRowItemClassName(row)} > ))} - )} + ) : null} @@ -1367,7 +1411,7 @@ function RowLabel({ row }: { row: RowEntry }): React.JSX.Element { if (row.kind === 'use-name') { return ( - {translate('auto.components.new.workspace.SmartWorkspaceNameField.b1a7d679ba', 'Use')} + {translate('auto.components.new.workspace.SmartWorkspaceNameField.b1a7d679ba', 'Use')}{' '} {translate('auto.components.new.workspace.SmartWorkspaceNameField.34ca97bce3', '"')} {row.name} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index dd53da51fa5..4dc89edc38c 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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, diff --git a/src/renderer/src/lib/workspace-composer-initial-focus.test.ts b/src/renderer/src/lib/workspace-composer-initial-focus.test.ts index 8e96a5ef221..aab1948092d 100644 --- a/src/renderer/src/lib/workspace-composer-initial-focus.test.ts +++ b/src/renderer/src/lib/workspace-composer-initial-focus.test.ts @@ -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 = ` + + + ` + + 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 = ` + +
+ ` + + 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 = ` diff --git a/src/renderer/src/lib/workspace-composer-initial-focus.ts b/src/renderer/src/lib/workspace-composer-initial-focus.ts index dd55c994daa..f04eaa002e9 100644 --- a/src/renderer/src/lib/workspace-composer-initial-focus.ts +++ b/src/renderer/src/lib/workspace-composer-initial-focus.ts @@ -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(WORKSPACE_NAME_INPUT_SELECTOR) ?? + root.querySelector(WORKSPACE_SOURCE_PILL_SELECTOR) ?? root.querySelector(PROJECT_COMBOBOX_TRIGGER_SELECTOR) ?? root.querySelector(LEGACY_REPO_COMBOBOX_TRIGGER_SELECTOR) ) diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index ae3977fc33c..352f57ab597 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -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 }) => {