feat(new-workspace): inline create-branch row, scoped suggestions, name under Advanced, tab/focus polish (#1469)

* feat(new-workspace): branch-tab create row, scoped empty hints, name under Advanced, focus polish

- Branch tab autocomplete now offers "Create new branch <name>" instead of a
  separate + button + dialog (replaces the original UX from #1408 / #1400).
  Suppressed when the typed query exactly matches an existing branch.
- "Use <X> as workspace name" row is restricted to Smart mode; on dedicated
  source tabs (GitHub/Linear/Branch) it was off-topic noise.
- Per-mode empty-state hints ("search GitHub PRs and issues", "find a branch
  or create a new one", etc.) replace the generic message.
- When a source (PR/issue/Linear/branch) is selected, the auto-derived
  workspace name is exposed as a "Name" input under Advanced where it can be
  reviewed/overridden. With an explicitly typed name the smart input itself
  is the name field, so no duplicated control.
- Forward Tab from the Repo combobox now skips the Smart/GitHub/Branch/Linear
  segmented control and lands directly in the search input. Shift-Tab from
  the input still focuses the active tab trigger so the segmented control is
  reachable in reverse. Implemented via a focusCapture interceptor on
  TabsList that distinguishes outside-entry from intra-list moves and from
  the input's own shift-tab.
- Trigger checkLinearConnection() on mount so the "Connect Linear in
  Settings" hint isn't shown stalely when the composer is the first
  Linear-aware surface to render in the session; gate the disconnected
  message on linearStatusChecked.

Co-authored-by: Orca <help@stably.ai>

* fix(new-workspace): Enter commits typed text while results are stale

When the user types faster than the 200ms search debounce, rows and
commandValue still reflect the previous query, so Enter would silently
pick a stale source row (e.g. a PR from the prior letters). Treat
results as stale while loading or while debouncedQuery hasn't caught up
to the input value, and commit the typed text via onValueChange instead.

Co-authored-by: Orca <help@stably.ai>

* fix(new-workspace): suppress stale source rows in autocomplete while typing

Source rows (GitHub items / branches / Linear issues) are driven by
debouncedQuery, so they're stale until the user pauses for the 200ms
debounce. The previous attempt only short-circuited Enter in the
input's onKeyDown, but cmdk's Command component has its own Enter
handler that still fires onSelect on the highlighted (stale) row.

Filter source rows out of the rows list itself when debouncedQuery
hasn't caught up to value, leaving only the typed-text row (use-name
in Smart, create-branch in Branches). With no stale rows present,
neither cmdk nor our own handler can pick a wrong source on Enter,
and the popover gives visual feedback (collapses to the typed-text
row) while results catch up.

Co-authored-by: Orca <help@stably.ai>

* fix(new-workspace): keep stale results visible, force highlight to typed-text row

Previous attempt removed source rows while debouncedQuery hadn't caught
up to value, which caused funky disappear/reappear flicker as the user
typed (rows present briefly while fresh, gone after the next keystroke).

Keep all source rows visible at all times to preserve continuity, but
control which row is highlighted while stale:
  - Smart/Branches: force highlight onto the typed-text row (use-name /
    create-branch) so cmdk's Enter handler commits the typed text instead
    of a stale issue/PR/branch.
  - GitHub/Linear: no typed-text fallback row exists, so clear the
    highlight; the input's onKeyDown falls through to onPlainEnter rather
    than picking a stale source.

Also fixed the input's Enter handler to fall through to onPlainEnter
when no row matches the (possibly empty) commandValue, so the keypress
isn't inert in the cleared-highlight case.

Co-authored-by: Orca <help@stably.ai>

* feat(new-workspace): auto-highlight matching source for #NNN, GH URLs, Linear IDs

When the typed value is unambiguously a source reference, snap the
autocomplete highlight onto the matching source row once it appears
instead of leaving it on the typed-text fallback. Enter then picks the
intended source.

Recognized patterns:
  - GitHub shorthand: #1234
  - GitHub URL: https://github.com/<owner>/<repo>/issues/123 or /pull/123
  - Linear identifier: STA-123 (case-insensitive [A-Z][A-Z0-9_]*-\d+)

Stale-results behavior is unchanged: while debouncedQuery hasn't caught
up to value, the typed-text row stays highlighted (Smart/Branches) or
the highlight is cleared (GitHub/Linear) regardless of intent.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-05-05 16:27:20 -07:00 committed by GitHub
parent 1b25138e06
commit d3388603bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 177 additions and 13 deletions

View File

@ -398,6 +398,25 @@ export default function NewWorkspaceComposerCard({
: '-translate-y-1 opacity-0 delay-0'
)}
>
{smartNameSelection ? (
// Why: when a source (PR/issue/Linear/branch) is picked the
// smart field shows a pill instead of an editable name, so
// surface the auto-derived workspace name here under Advanced
// where it can be reviewed/overridden. When the user typed an
// explicit name there's no source pill — the smart input is
// already the name field, so we don't duplicate it here.
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
type="text"
value={name}
onChange={(event) => onNameValueChange(event.target.value)}
placeholder="Workspace name"
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
) : null}
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Note</label>
<textarea

View File

@ -7,6 +7,7 @@ import {
CircleDot,
ExternalLink,
GitBranch,
GitBranchPlus,
GitPullRequest,
Github,
LoaderCircle,
@ -86,8 +87,17 @@ const MODES: {
{ id: 'text', label: 'Name', Icon: CaseSensitive }
]
const emptyHintByMode: Record<SmartNameMode, string> = {
smart: 'Start typing to create a name or find a source.',
github: 'Start typing to search GitHub PRs and issues.',
branches: 'Start typing to find a branch or create a new one.',
linear: 'Start typing to search Linear issues.',
text: ''
}
type RowEntry =
| { kind: 'use-name'; value: string; name: string }
| { kind: 'create-branch'; value: string; name: string }
| { kind: 'github'; value: string; item: GitHubWorkItem }
| { kind: 'branch'; value: string; refName: string }
| { kind: 'linear'; value: string; issue: LinearIssue }
@ -108,17 +118,21 @@ export default function SmartWorkspaceNameField({
}: SmartWorkspaceNameFieldProps): React.JSX.Element {
const {
addRepo,
checkLinearConnection,
fetchWorkItems,
getCachedWorkItems,
linearStatus,
linearStatusChecked,
listLinearIssues,
searchLinearIssues
} = useAppStore(
useShallow((s) => ({
addRepo: s.addRepo,
checkLinearConnection: s.checkLinearConnection,
fetchWorkItems: s.fetchWorkItems,
getCachedWorkItems: s.getCachedWorkItems,
linearStatus: s.linearStatus,
linearStatusChecked: s.linearStatusChecked,
listLinearIssues: s.listLinearIssues,
searchLinearIssues: s.searchLinearIssues
}))
@ -157,6 +171,16 @@ export default function SmartWorkspaceNameField({
[inputRef]
)
useEffect(() => {
// Why: the composer can be opened before any other Linear-aware surface
// (TaskPage, IntegrationsPane) has had a chance to refresh status, leaving
// `linearStatus.connected=false` even when the user is actually connected.
// Trigger a check on mount if it hasn't run this session.
if (!linearStatusChecked) {
void checkLinearConnection()
}
}, [checkLinearConnection, linearStatusChecked])
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedQuery(value), SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
@ -371,9 +395,28 @@ export default function SmartWorkspaceNameField({
const rows = useMemo<RowEntry[]>(() => {
const trimmed = value.trim()
const nextRows: RowEntry[] = trimmed
? [{ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }]
: []
// Why: on the Branches tab the generic "Use … as workspace name" row
// reads as off-topic — the user is picking/creating a branch. Swap it
// for a branch-creation row that's pinned above existing-branch results
// (suppressed when an existing branch matches exactly so we don't offer
// to "create" something that already exists).
const branchExactMatch = mode === 'branches' && trimmed.length > 0 && branches.includes(trimmed)
// Why: the "Use … as workspace name" row only makes sense in Smart
// mode, where the user might be typing a free-form name. On dedicated
// source tabs (GitHub/Linear/Branches) it's off-topic — the user is
// there to pick (or, on Branches, create) a source.
const useNameRow: RowEntry | null =
trimmed && mode === 'smart'
? { kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }
: null
const createBranchRow: RowEntry | null =
trimmed && mode === 'branches' && !branchExactMatch
? { kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }
: null
const nextRows: RowEntry[] = []
if (useNameRow) {
nextRows.push(useNameRow)
}
if (mode === 'text') {
return nextRows
}
@ -387,6 +430,9 @@ export default function SmartWorkspaceNameField({
)
}
if (mode === 'smart' || mode === 'branches') {
if (createBranchRow) {
nextRows.push(createBranchRow)
}
nextRows.push(
...branches.map((refName) => ({
kind: 'branch' as const,
@ -407,20 +453,78 @@ export default function SmartWorkspaceNameField({
return nextRows.slice(0, RESULT_LIMIT + 1)
}, [branches, githubItems, linearIssues, mode, value])
useEffect(() => {
if (rows.length > 0) {
setCommandValue((current) =>
rows.some((row) => row.value === current) ? current : rows[0].value
)
// Why: source rows (GitHub/branches/Linear) are driven by debouncedQuery,
// so they're stale until the user pauses typing for SEARCH_DEBOUNCE_MS.
// We don't want to filter them out (causes flicker as results appear and
// disappear with each keystroke), but we do need to prevent cmdk's Enter
// handler from auto-selecting a stale source row. Two cases:
// - Smart/Branches: a typed-text row (use-name / create-branch) exists
// and is pinned at the top — force the highlight onto it so Enter
// commits the typed text instead of a stale issue/PR/branch.
// - GitHub/Linear: no typed-text fallback row, so clear the highlight
// entirely; the input's Enter handler falls through to onPlainEnter.
const isQueryStale = value.trim().length > 0 && debouncedQuery.trim() !== value.trim()
// Why: when the typed value is unambiguously a source reference — a
// GitHub issue/PR shorthand ("#1234"), a github.com issue/pull URL, or a
// Linear identifier ("STA-123") — the user is clearly looking up that
// specific source rather than naming a workspace. Once a matching row
// appears in the results, snap the highlight onto it so Enter picks it
// instead of the typed-text fallback.
const sourceIntent = useMemo<'github' | 'linear' | null>(() => {
const trimmed = value.trim()
if (!trimmed) {
return null
}
}, [rows])
if (/^#\d+$/.test(trimmed) || parseGitHubIssueOrPRLink(trimmed) !== null) {
return 'github'
}
if (/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(trimmed)) {
return 'linear'
}
return null
}, [value])
useEffect(() => {
if (rows.length === 0) {
return
}
if (isQueryStale) {
const typedTextRow = rows.find(
(row) => row.kind === 'use-name' || row.kind === 'create-branch'
)
// No typed-text fallback in this mode (GitHub/Linear): clear the
// highlight so cmdk doesn't auto-select a stale source on Enter.
setCommandValue(typedTextRow ? typedTextRow.value : '')
return
}
if (sourceIntent === 'github') {
const githubRow = rows.find((row) => row.kind === 'github')
if (githubRow) {
setCommandValue(githubRow.value)
return
}
} else if (sourceIntent === 'linear') {
const linearRow = rows.find((row) => row.kind === 'linear')
if (linearRow) {
setCommandValue(linearRow.value)
return
}
}
setCommandValue((current) =>
rows.some((row) => row.value === current) ? current : rows[0].value
)
}, [isQueryStale, rows, sourceIntent])
const loading = githubLoading || branchesLoading || linearLoading
const ActiveInputIcon = mode === 'text' ? CaseSensitive : loading ? LoaderCircle : Search
const handleSelect = useCallback(
(row: RowEntry) => {
if (row.kind === 'use-name') {
if (row.kind === 'use-name' || row.kind === 'create-branch') {
// Why: "create new branch" has no existing ref to base from, so
// it follows the same path as a typed name — the workspace's branch
// is derived from `name` and `baseBranch` stays unset (default base).
onValueChange(row.name)
} else if (row.kind === 'github') {
onGitHubItemSelect(row.item)
@ -517,6 +621,31 @@ export default function SmartWorkspaceNameField({
ref={tabsListRef}
variant="line"
className="h-7 w-full justify-start gap-4 border-b border-border/40 px-0"
onFocusCapture={(event) => {
// Why: Radix Tabs uses roving focus and re-applies tabindex=0 to
// the active trigger on every render, so we can't keep it out of
// the natural Tab order via props or a MutationObserver (race
// with React commits). Instead, intercept focus *on entry* into
// the tabs list:
// - Forward Tab from outside (e.g., Repo combobox) → bounce to
// the search input so the segmented control is skipped.
// - Shift-Tab from the input → relatedTarget is the input, so
// allow focus to land on the active trigger (segmented
// control remains reachable in reverse).
// - Intra-list focus moves (arrow keys) → relatedTarget is
// inside the list; allow.
const previous = event.relatedTarget as HTMLElement | null
const list = tabsListRef.current
const input = localInputRef.current
if (!list || !input) {
return
}
if (!previous || previous === input || list.contains(previous)) {
return
}
event.stopPropagation()
input.focus({ preventScroll: true })
}}
>
{MODES.map(({ id, label, Icon }) => (
<TabsTrigger
@ -633,8 +762,13 @@ export default function SmartWorkspaceNameField({
if (row) {
event.preventDefault()
handleSelect(row)
return
}
return
// No highlighted row (e.g., stale results in
// GitHub/Linear modes where the highlight was
// cleared to avoid auto-selecting a stale source).
// Fall through to onPlainEnter so the keypress
// doesn't feel inert.
}
onPlainEnter?.()
}
@ -687,9 +821,9 @@ export default function SmartWorkspaceNameField({
</div>
) : rows.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{mode === 'linear' && !linearStatus.connected
{mode === 'linear' && linearStatusChecked && !linearStatus.connected
? 'Connect Linear in Settings to search issues.'
: 'Start typing to create a name or find a source.'}
: emptyHintByMode[mode]}
</div>
) : (
<CommandGroup className="p-1">
@ -747,6 +881,9 @@ function RowIcon({ row }: { row: RowEntry }): React.JSX.Element {
if (row.kind === 'use-name') {
return <CaseSensitive className="size-3.5 shrink-0 text-muted-foreground" />
}
if (row.kind === 'create-branch') {
return <GitBranchPlus className="size-3.5 shrink-0 text-muted-foreground" />
}
if (row.kind === 'github') {
return row.item.type === 'pr' ? (
<GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
@ -790,6 +927,14 @@ function RowLabel({ row }: { row: RowEntry }): React.JSX.Element {
</span>
)
}
if (row.kind === 'create-branch') {
return (
<span className="min-w-0 truncate">
Create new branch{' '}
<span className="font-mono text-[11px] font-medium text-foreground">{row.name}</span>
</span>
)
}
if (row.kind === 'github') {
return (
<span className="min-w-0 truncate">