fix(new-workspace): keep workspace creation reachable with zero projects (#15234)

The sidebar +, the landing Create button, the board lane +, the palette
create row, and the tour CTA all disabled themselves when repos was
empty — a dead end, since the composer's project field can add the first
project inline and auto-select it.

Drop the repo-count gate from every entry point. Submitting without a
project still shows the inline "Choose or add a project" error.
This commit is contained in:
Neil
2026-08-17 21:47:29 -07:00
committed by GitHub
parent 19ba83d496
commit f48bdf8f59
17 changed files with 124 additions and 85 deletions
+3 -9
View File
@@ -231,7 +231,7 @@ export default function Landing(): React.JSX.Element {
const createTargetLabel =
repos.length > 0 && repos.every((repo) => isGitRepoKind(repo)) ? 'Worktree' : 'Workspace'
const canCreateWorktree = repos.length > 0
const hasProjects = repos.length > 0
const hasGitHubProject = useMemo(() => hasGitHubBackedProject(repos), [repos])
const showGitHubSupportFooter = repos.length === 0 || hasGitHubProject
@@ -274,7 +274,7 @@ export default function Landing(): React.JSX.Element {
{preflightIssues.length > 0 && <PreflightBanner issues={preflightIssues} repos={repos} />}
<p className="text-sm text-muted-foreground text-center">
{canCreateWorktree
{hasProjects
? translate(
'auto.components.Landing.9c00bd4adf',
'Select a workspace from the sidebar to begin.'
@@ -292,13 +292,7 @@ export default function Landing(): React.JSX.Element {
</button>
<button
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-accent"
disabled={!canCreateWorktree}
title={
!canCreateWorktree
? translate('auto.components.Landing.f05d237049', 'Add a project first')
: undefined
}
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md cursor-pointer hover:bg-accent transition-colors"
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
>
<GitBranchPlus className="size-3.5" />
@@ -848,7 +848,6 @@ function WorktreeJumpPaletteContent({
hostLabelOverrides
]
)
const canCreateWorktree = repos.length > 0
// Why: host-less repos and worktrees inherit the focused runtime host, exactly
// as the sidebar's host headers do — otherwise the two disagree on bucketing.
@@ -1815,14 +1814,13 @@ function WorktreeJumpPaletteContent({
} = useMemo(
() =>
getWorktreePaletteCreateActionState({
canCreateWorktree,
query: deferredQuery
}),
[canCreateWorktree, deferredQuery]
[deferredQuery]
)
const createWorktreeName = taskSourceUrl ? query.trim() : deferredCreateWorktreeName
// Why still gated: a task URL bypasses query deferral, not creation eligibility.
const showCreateAction = deferredShowCreateAction || (taskSourceUrl !== null && canCreateWorktree)
// Why: a task URL bypasses query deferral, so it arms create on its own.
const showCreateAction = deferredShowCreateAction || taskSourceUrl !== null
// Why: arm the lookup before Enter can target the newly rendered Linear row.
useLayoutEffect(() => {
@@ -39,7 +39,6 @@ export function ContextualTourOverlay(): JSX.Element | null {
const keybindings = useAppStore((s) => s.keybindings)
const activeTabId = useAppStore((s) => s.activeTabId)
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
const canCreateWorkspace = useAppStore((s) => s.repos.length > 0)
const markContextualToursSeen = useAppStore((s) => s.markContextualToursSeen)
const advanceContextualTour = useAppStore((s) => s.advanceContextualTour)
const regressContextualTour = useAppStore((s) => s.regressContextualTour)
@@ -316,7 +315,6 @@ export function ContextualTourOverlay(): JSX.Element | null {
setSidebarOpen,
openTaskPage,
openModal,
canCreateWorkspace,
openWorkspaceComposer: openWorkspaceCreationComposerWithTourHandoff,
dispatchTerminalPaneSplit: requestActiveTerminalPaneSplit,
schedule: (callback) => {
@@ -18,7 +18,6 @@ describe('performContextualTourStepAction', () => {
setSidebarOpen: vi.fn(),
openTaskPage,
openModal: vi.fn(),
canCreateWorkspace: true,
openWorkspaceComposer: vi.fn(),
dispatchTerminalPaneSplit: vi.fn(),
schedule: vi.fn()
@@ -43,7 +42,6 @@ describe('performContextualTourStepAction', () => {
setSidebarOpen: vi.fn(),
openTaskPage: vi.fn(),
openModal: vi.fn(),
canCreateWorkspace: true,
openWorkspaceComposer: vi.fn(),
dispatchTerminalPaneSplit,
schedule: vi.fn()
@@ -71,7 +69,6 @@ describe('performContextualTourStepAction', () => {
setSidebarOpen: vi.fn(),
openTaskPage: vi.fn(),
openModal: vi.fn(),
canCreateWorkspace: true,
openWorkspaceComposer,
dispatchTerminalPaneSplit: vi.fn(),
schedule: vi.fn()
@@ -85,7 +82,7 @@ describe('performContextualTourStepAction', () => {
expect(finishTour).not.toHaveBeenCalled()
})
it('does not open the workspace composer when workspace creation is unavailable', () => {
it('opens the workspace composer with no projects so the first one can be added there', () => {
const detachContextualTourSource = vi.fn()
const openWorkspaceComposer = vi.fn()
@@ -99,13 +96,12 @@ describe('performContextualTourStepAction', () => {
setSidebarOpen: vi.fn(),
openTaskPage: vi.fn(),
openModal: vi.fn(),
canCreateWorkspace: false,
openWorkspaceComposer,
dispatchTerminalPaneSplit: vi.fn(),
schedule: vi.fn()
})
expect(detachContextualTourSource).not.toHaveBeenCalled()
expect(openWorkspaceComposer).not.toHaveBeenCalled()
expect(detachContextualTourSource).toHaveBeenCalledTimes(1)
expect(openWorkspaceComposer).toHaveBeenCalledTimes(1)
})
})
@@ -11,7 +11,6 @@ export function performContextualTourStepAction(args: {
setSidebarOpen: (open: boolean) => void
openTaskPage: () => void
openModal: (modal: 'setup-guide', data?: Record<string, unknown>) => void
canCreateWorkspace: boolean
openWorkspaceComposer: () => void
dispatchTerminalPaneSplit: (detail: RequestActiveTerminalPaneSplitDetail) => void
schedule: (callback: () => void) => void
@@ -37,14 +36,12 @@ export function performContextualTourStepAction(args: {
}
return
case 'create-worktree':
if (args.canCreateWorkspace) {
// Why: opening the composer cancels this tour (it isn't allowed over the
// modal) and hands off to the workspace-creation tour. Detach first so the
// terminal source's unmount cleanup can't record a stray suppression.
args.detachContextualTourSource()
args.setSidebarOpen(true)
args.openWorkspaceComposer()
}
// Why: opening the composer cancels this tour (it isn't allowed over the
// modal) and hands off to the workspace-creation tour. Detach first so the
// terminal source's unmount cleanup can't record a stray suppression.
args.detachContextualTourSource()
args.setSidebarOpen(true)
args.openWorkspaceComposer()
return
case 'show-worktrees':
args.setSidebarOpen(true)
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import SidebarHeader from './SidebarHeader'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const mocks = vi.hoisted(() => ({
openWorkspaceCreationComposerWithTourHandoff: vi.fn()
}))
type MockState = {
repos: { id: string }[]
groupBy: string
openModal: (modal: string, data?: unknown) => void
}
let mockState: MockState
vi.mock('@/store', () => ({
useAppStore: (selector: (state: MockState) => unknown) => selector(mockState)
}))
vi.mock('./SidebarWorkspaceOptionsMenu', () => ({ default: () => null }))
vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => '⌘N' }))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
vi.mock('../contextual-tours/workspace-creation-tour-handoff', () => ({
openWorkspaceCreationComposerWithTourHandoff: mocks.openWorkspaceCreationComposerWithTourHandoff
}))
let container: HTMLDivElement
let root: Root
function newWorkspaceButton(): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>('[aria-label="New workspace"]')
if (!button) {
throw new Error('New workspace button not rendered')
}
return button
}
beforeEach(() => {
mocks.openWorkspaceCreationComposerWithTourHandoff.mockClear()
mockState = { repos: [], groupBy: 'repo', openModal: vi.fn() }
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
describe('SidebarHeader', () => {
it('keeps New workspace clickable with zero projects, since the composer adds the first one', () => {
act(() => {
root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />)
})
const button = newWorkspaceButton()
expect(button.disabled).toBe(false)
act(() => {
button.click()
})
expect(mocks.openWorkspaceCreationComposerWithTourHandoff).toHaveBeenCalledTimes(1)
})
it('opens the composer the same way once projects exist', () => {
mockState.repos = [{ id: 'repo-a' }]
act(() => {
root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />)
})
act(() => {
newWorkspaceButton().click()
})
expect(newWorkspaceButton().disabled).toBe(false)
expect(mocks.openWorkspaceCreationComposerWithTourHandoff).toHaveBeenCalledTimes(1)
})
})
@@ -18,7 +18,6 @@ const SidebarHeader = React.memo(function SidebarHeader({
const openModal = useAppStore((s) => s.openModal)
const newWorktreeShortcutLabel = useShortcutLabel('workspace.create')
const groupBy = useAppStore((s) => s.groupBy)
const canCreateWorkspace = useAppStore((s) => s.repos.length > 0)
const sidebarTitle = groupBy === 'repo' ? 'Projects' : 'Workspaces'
return (
@@ -62,35 +61,24 @@ const SidebarHeader = React.memo(function SidebarHeader({
<Button
variant="ghost"
size="icon-xs"
onClick={() => {
if (!canCreateWorkspace) {
return
}
// Why: the parallel-work tour must click the real sidebar
// control so it can hand off to the workspace-creation tour.
openWorkspaceCreationComposerWithTourHandoff()
}}
// Why: the parallel-work tour must click the real sidebar
// control so it can hand off to the workspace-creation tour.
onClick={openWorkspaceCreationComposerWithTourHandoff}
aria-label={translate(
'auto.components.sidebar.SidebarHeader.92154beb7e',
'New workspace'
)}
disabled={!canCreateWorkspace}
data-contextual-tour-target="workspace-create-control"
>
<Plus className="size-3.5" strokeWidth={2.25} />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{canCreateWorkspace
? translate(
'auto.components.sidebar.SidebarHeader.ca6f729da2',
'New workspace ({{value0}})',
{ value0: newWorktreeShortcutLabel }
)
: translate(
'auto.components.sidebar.SidebarHeader.5c9c7c16aa',
'Add a project to create workspaces'
)}
{translate(
'auto.components.sidebar.SidebarHeader.ca6f729da2',
'New workspace ({{value0}})',
{ value0: newWorktreeShortcutLabel }
)}
</TooltipContent>
</Tooltip>
</div>
@@ -130,7 +130,6 @@ vi.mock('./use-workspace-kanban-column-resize', () => ({
vi.mock('./use-workspace-kanban-create-worktree', () => ({
useWorkspaceKanbanCreateWorktree: () => ({
canCreateWorktree: true,
createWorktreeForStatus: vi.fn()
})
}))
@@ -138,7 +138,6 @@ vi.mock('./use-workspace-kanban-column-resize', () => ({
vi.mock('./use-workspace-kanban-create-worktree', () => ({
useWorkspaceKanbanCreateWorktree: () => ({
canCreateWorktree: true,
createWorktreeForStatus: vi.fn()
})
}))
@@ -203,7 +203,7 @@ function WorkspaceKanbanDrawerContent({
const activeWorktreeIdentity = activeWorktreeId
? composeWorktreeHostIdentity(activeWorkspaceExecutionHostId ?? undefined, activeWorktreeId)
: null
const { canCreateWorktree, createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree()
const { createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree()
const visibleWorktreeIdSet = useVisibleWorkspaceKanbanWorktreeIds({
allWorktrees,
repoMap
@@ -944,7 +944,6 @@ function WorkspaceKanbanDrawerContent({
columnWidth={columnWidth}
isResizingColumn={isResizingColumn}
dragOverStatus={dragOverStatus}
canCreateWorktree={canCreateWorktree}
renderCards={renderCards}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={renderedSelectedWorktrees}
@@ -108,7 +108,6 @@ function makeGrid(activeWorktreeIdentity: string | null = null): React.JSX.Eleme
columnWidth={308}
isResizingColumn={false}
dragOverStatus={null}
canCreateWorktree={true}
renderCards={true}
selectedWorktreeIds={new Set()}
selectedWorktrees={[]}
@@ -35,7 +35,6 @@ type WorkspaceKanbanLaneGridProps = {
columnWidth: number
isResizingColumn: boolean
dragOverStatus: WorkspaceStatus | null
canCreateWorktree: boolean
renderCards: boolean
selectedWorktreeIds: ReadonlySet<string>
selectedWorktrees: readonly Worktree[]
@@ -65,7 +64,6 @@ export default function WorkspaceKanbanLaneGrid({
columnWidth,
isResizingColumn,
dragOverStatus,
canCreateWorktree,
renderCards,
selectedWorktreeIds,
selectedWorktrees,
@@ -210,7 +208,6 @@ export default function WorkspaceKanbanLaneGrid({
columnWidth={columnWidth}
isResizingColumn={isResizingColumn}
isDragTarget={dragOverStatus === status.id}
canCreateWorktree={canCreateWorktree}
renderCards={renderCards && renderedLaneIds.has(status.id)}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
@@ -62,7 +62,6 @@ function renderLane(props: {
columnWidth={308}
isResizingColumn={false}
isDragTarget={false}
canCreateWorktree={true}
renderCards={true}
selectedWorktreeIds={new Set()}
selectedWorktrees={[]}
@@ -30,7 +30,6 @@ type WorkspaceKanbanStatusLaneProps = {
columnWidth: number
isResizingColumn: boolean
isDragTarget: boolean
canCreateWorktree: boolean
nativeDragEnabled?: boolean
renderCards: boolean
selectedWorktreeIds: ReadonlySet<string>
@@ -61,7 +60,6 @@ function WorkspaceKanbanStatusLane({
columnWidth,
isResizingColumn,
isDragTarget,
canCreateWorktree,
nativeDragEnabled = true,
renderCards,
selectedWorktreeIds,
@@ -94,9 +92,7 @@ function WorkspaceKanbanStatusLane({
undefined
)
}, [fullWorktreeIds, hasQuery, items])
const createTooltip = canCreateWorktree
? `New workspace in ${status.label}`
: 'Add a project to create workspaces'
const createTooltip = `New workspace in ${status.label}`
const createButton = (
<Button
type="button"
@@ -104,7 +100,6 @@ function WorkspaceKanbanStatusLane({
size="icon-xs"
className="size-6 text-muted-foreground"
aria-label={createTooltip}
disabled={!canCreateWorktree}
onClick={() => onCreateWorktree(status.id)}
>
<Plus className="size-3.5" />
@@ -224,7 +219,6 @@ function WorkspaceKanbanStatusLane({
'group-hover/lane:opacity-100 group-focus-within/lane:opacity-100'
)}
aria-label={createTooltip}
disabled={!canCreateWorktree}
onClick={() => onCreateWorktree(status.id)}
>
<Plus className="size-3.5" />
@@ -3,11 +3,9 @@ import { useAppStore } from '@/store'
import type { WorkspaceStatus } from '../../../../shared/worktree/types'
export function useWorkspaceKanbanCreateWorktree(): {
canCreateWorktree: boolean
createWorktreeForStatus: (workspaceStatus: WorkspaceStatus) => void
} {
const openModal = useAppStore((s) => s.openModal)
const canCreateWorktree = useAppStore((s) => s.repos.length > 0)
const createWorktreeForStatus = useCallback(
(workspaceStatus: WorkspaceStatus) => {
@@ -19,5 +17,5 @@ export function useWorkspaceKanbanCreateWorktree(): {
[openModal]
)
return { canCreateWorktree, createWorktreeForStatus }
return { createWorktreeForStatus }
}
@@ -13,7 +13,6 @@ import { WORKTREE_PALETTE_QUERY_MAX_BYTES } from './worktree-palette-query-bound
describe('worktree-palette-create-action', () => {
it('shows create for typed queries with workspace matches but selects the first workspace row', () => {
const state = getWorktreePaletteCreateActionState({
canCreateWorktree: true,
query: 'feature'
})
@@ -33,7 +32,6 @@ describe('worktree-palette-create-action', () => {
it('skips create for free text even when it is listed before every other row', () => {
const state = getWorktreePaletteCreateActionState({
canCreateWorktree: true,
query: 'opencode-issue'
})
@@ -77,7 +75,6 @@ describe('worktree-palette-create-action', () => {
it('leaves Enter unarmed for typed queries with no real matches', () => {
const state = getWorktreePaletteCreateActionState({
canCreateWorktree: true,
query: 'new-workspace'
})
@@ -156,19 +153,17 @@ describe('worktree-palette-create-action', () => {
).toBe(CREATE_WORKTREE_ITEM_ID)
})
it('hides create when no project is available to create a workspace in', () => {
it('offers create with no projects, since the composer adds the first one inline', () => {
expect(
getWorktreePaletteCreateActionState({
canCreateWorktree: false,
query: 'new-workspace'
})
).toEqual({ createWorktreeName: 'new-workspace', showCreateAction: false })
).toEqual({ createWorktreeName: 'new-workspace', showCreateAction: true })
})
it('hides create for an empty query', () => {
expect(
getWorktreePaletteCreateActionState({
canCreateWorktree: true,
query: ' '
}).showCreateAction
).toBe(false)
@@ -179,7 +174,6 @@ describe('worktree-palette-create-action', () => {
expect(
getWorktreePaletteCreateActionState({
canCreateWorktree: true,
query: oversizedQuery
})
).toEqual({
@@ -8,10 +8,8 @@ export type WorktreePaletteCreateActionState = {
}
export function getWorktreePaletteCreateActionState({
canCreateWorktree,
query
}: {
canCreateWorktree: boolean
query: string
}): WorktreePaletteCreateActionState {
const createWorktreeName = query.trim()
@@ -21,12 +19,11 @@ export function getWorktreePaletteCreateActionState({
showCreateAction: false
}
}
// Why gate on eligibility: creation must not be offered — or reachable by Enter —
// when there is no repo to create a workspace in.
const showCreateAction = canCreateWorktree && createWorktreeName.length > 0
// Why no project gate: the composer can add the first project inline, so
// creation stays offered with zero projects.
return {
createWorktreeName,
showCreateAction
showCreateAction: createWorktreeName.length > 0
}
}