Always open workspace composer before projects (#4991)

This commit is contained in:
Neil 2026-06-09 00:27:39 -07:00 committed by GitHub
parent ec083480da
commit 8ec5d599d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 105 additions and 41 deletions

View File

@ -197,9 +197,8 @@ export default function Landing(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const openModal = useAppStore((s) => s.openModal)
const canCreateWorktree = repos.length > 0
const createTargetLabel =
canCreateWorktree && repos.every((repo) => isGitRepoKind(repo)) ? 'Worktree' : 'Workspace'
repos.length > 0 && repos.every((repo) => isGitRepoKind(repo)) ? 'Worktree' : 'Workspace'
const [preflightIssues, setPreflightIssues] = useState<PreflightIssue[]>([])
@ -288,7 +287,7 @@ export default function Landing(): React.JSX.Element {
{preflightIssues.length > 0 && <PreflightBanner issues={preflightIssues} />}
<p className="text-sm text-muted-foreground text-center">
{canCreateWorktree
{repos.length > 0
? 'Select a workspace from the sidebar to begin.'
: 'Add a project to get started.'}
</p>
@ -303,9 +302,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 ? '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 transition-colors hover:bg-accent"
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
>
<GitBranchPlus className="size-3.5" />

View File

@ -68,6 +68,7 @@ type NewWorkspaceComposerCardProps = {
advancedOpen: boolean
onToggleAdvanced: () => void
createDisabled: boolean
projectError: string | null
creating: boolean
onCreate: () => void
note: string
@ -236,6 +237,7 @@ export default function NewWorkspaceComposerCard({
advancedOpen,
onToggleAdvanced,
createDisabled,
projectError,
creating,
onCreate,
note,
@ -358,6 +360,7 @@ export default function NewWorkspaceComposerCard({
const handleAddRepo = React.useCallback((): void => {
openModal('add-repo')
}, [openModal])
const projectDescriptionId = React.useId()
useContextualTour(
'workspace-creation',
eligibleRepos.length > 0 && Boolean(repoId),
@ -420,7 +423,18 @@ export default function NewWorkspaceComposerCard({
// focus state.
triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50"
showStandaloneAddButton={false}
invalid={Boolean(projectError)}
describedBy={projectDescriptionId}
/>
{projectError ? (
<p id={projectDescriptionId} className="text-[11px] text-destructive">
{projectError}
</p>
) : eligibleRepos.length === 0 ? (
<p id={projectDescriptionId} className="text-[11px] text-muted-foreground">
Add a project before creating a workspace.
</p>
) : null}
{selectedRepoRequiresConnection && selectedRepoConnectionId ? (
<div
role="status"

View File

@ -118,7 +118,7 @@ describe('openWorkspaceCreationComposerWithTourHandoff', () => {
expect(requestContextualTourWhenReady).not.toHaveBeenCalled()
})
it('does not open the composer when no project is available for workspace creation', () => {
it('opens the composer without a project so the empty form can guide setup', () => {
const openModal = vi.fn()
const detachContextualTourSource = vi.fn()
vi.spyOn(useAppStore, 'getState').mockImplementation(
@ -138,7 +138,9 @@ describe('openWorkspaceCreationComposerWithTourHandoff', () => {
openWorkspaceCreationComposerWithTourHandoff()
expect(detachContextualTourSource).not.toHaveBeenCalled()
expect(openModal).not.toHaveBeenCalled()
expect(openModal).toHaveBeenCalledWith('new-workspace-composer', {
telemetrySource: 'sidebar'
})
expect(requestContextualTourWhenReady).not.toHaveBeenCalled()
})
})

View File

@ -3,11 +3,10 @@ import { requestContextualTourWhenReady } from './request-contextual-tour-when-r
export function openWorkspaceCreationComposerWithTourHandoff(): void {
const state = useAppStore.getState()
if (state.repos.length === 0) {
return
}
const hasProjects = state.repos.length > 0
const shouldHandoffFromAgentSessionsTour =
hasProjects &&
state.activeContextualTourId === 'workspace-agent-sessions' &&
state.activeContextualTourStepIndex === 1

View File

@ -26,6 +26,8 @@ type RepoComboboxProps = {
triggerClassName?: string
autoOpenOnMount?: boolean
showStandaloneAddButton?: boolean
invalid?: boolean
describedBy?: string
}
export default function RepoCombobox({
@ -36,7 +38,9 @@ export default function RepoCombobox({
placeholder = 'Select repo...',
triggerClassName,
autoOpenOnMount = false,
showStandaloneAddButton = true
showStandaloneAddButton = true,
invalid = false,
describedBy
}: RepoComboboxProps): React.JSX.Element {
const [open, setOpen] = useState(autoOpenOnMount)
const [query, setQuery] = useState('')
@ -185,6 +189,8 @@ export default function RepoCombobox({
variant="outline"
role="combobox"
aria-expanded={open}
aria-invalid={invalid ? true : undefined}
aria-describedby={describedBy}
onKeyDown={handleTriggerKeyDown}
className={cn(
'h-8 min-w-[184px] justify-between px-3 text-xs font-normal',

View File

@ -13,9 +13,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false)
const [workspaceBoardMenuOpen, setWorkspaceBoardMenuOpen] = useState(false)
const workspaceBoardOpenRef = useRef(workspaceBoardOpen)
const repos = useAppStore((s) => s.repos)
const groupBy = useAppStore((s) => s.groupBy)
const canCreateWorkspace = repos.length > 0
const sidebarTitle = groupBy === 'repo' ? 'Projects' : 'Workspaces'
workspaceBoardOpenRef.current = workspaceBoardOpen
@ -129,24 +127,18 @@ const SidebarHeader = React.memo(function SidebarHeader() {
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()
}}
aria-label="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
? `New workspace (${newWorktreeShortcutLabel})`
: 'Add a project to create workspaces'}
{`New workspace (${newWorktreeShortcutLabel})`}
</TooltipContent>
</Tooltip>
</div>

View File

@ -184,6 +184,7 @@ export type ComposerCardProps = {
advancedOpen: boolean
onToggleAdvanced: () => void
createDisabled: boolean
projectError: string | null
creating: boolean
onCreate: () => void
note: string
@ -333,6 +334,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
})
const [internalRepoId, setInternalRepoId] = useState<string>(resolvedInitialRepoId)
const [projectError, setProjectError] = useState<string | null>(null)
const repoId = repoIdOverride ?? internalRepoId
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false
@ -1521,6 +1523,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const handleRepoChange = useCallback(
(value: string): void => {
setProjectError(null)
if (value === repoId) {
setRepoId(value)
return
@ -1567,6 +1570,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[baseBranch, linkedWorkItem, repoId, setRepoId]
)
const showProjectRequiredError = useCallback((): void => {
setProjectError('Choose or add a project before creating a workspace.')
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(
'[data-contextual-tour-target="workspace-creation-project"] [data-repo-combobox-root="true"][role="combobox"]'
)
?.focus()
})
}, [])
const handleSparseSelectPreset = useCallback((preset: SparsePreset | null): void => {
if (preset) {
setSparseEnabled(true)
@ -1860,10 +1874,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
const submit = useCallback(async (): Promise<void> => {
if (!repoId || !selectedRepo) {
showProjectRequiredError()
return
}
if (
!repoId ||
!workspaceSeedName ||
!selectedRepo ||
selectedRepoRequiresConnection ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
@ -2109,6 +2125,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepo,
selectedRepoIsGit,
selectedRepoRequiresConnection,
showProjectRequiredError,
settings?.agentCmdOverrides,
settings?.autoRenameBranchFromWork,
setSidebarOpen,
@ -2138,10 +2155,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
linkedPR,
fallbackName: fallbackCreatureName
})
if (!repoId || !selectedRepo) {
showProjectRequiredError()
return
}
if (
!repoId ||
!workspaceNameSeed ||
!selectedRepo ||
selectedRepoRequiresConnection ||
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
@ -2357,6 +2376,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepo,
selectedRepoIsGit,
selectedRepoRequiresConnection,
showProjectRequiredError,
settings?.agentCmdOverrides,
settings?.autoRenameBranchFromWork,
disabledTuiAgents,
@ -2427,6 +2447,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
advancedOpen,
onToggleAdvanced: () => setAdvancedOpen((current) => !current),
createDisabled,
projectError,
creating,
onCreate: () => void submit(),
baseBranch,

View File

@ -3,6 +3,7 @@ import type * as ReactModule from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildNewWorkspaceShortcutModalData,
openNewWorkspaceFromShortcut,
resolveBrowserSessionTabTarget,
resolveZoomTarget
} from './useIpcEvents'
@ -380,6 +381,36 @@ describe('buildNewWorkspaceShortcutModalData', () => {
})
})
describe('openNewWorkspaceFromShortcut', () => {
it('opens the composer even when no project has been added yet', () => {
const openModal = vi.fn()
openNewWorkspaceFromShortcut({
activeModal: 'none',
activeView: 'terminal',
taskPageData: {},
openModal
} as never)
expect(openModal).toHaveBeenCalledWith('new-workspace-composer', {
telemetrySource: 'shortcut'
})
})
it('does not reopen the composer when it is already active', () => {
const openModal = vi.fn()
openNewWorkspaceFromShortcut({
activeModal: 'new-workspace-composer',
activeView: 'terminal',
taskPageData: {},
openModal
} as never)
expect(openModal).not.toHaveBeenCalled()
})
})
describe('useIpcEvents browser tab create routing', () => {
beforeEach(() => {
vi.resetModules()

View File

@ -643,6 +643,15 @@ export function buildNewWorkspaceShortcutModalData(
}
}
export function openNewWorkspaceFromShortcut(
state: Pick<AppState, 'activeModal' | 'activeView' | 'taskPageData' | 'openModal'>
): void {
if (state.activeModal === 'new-workspace-composer') {
return
}
state.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(state))
}
export function resolveBrowserSessionTabTarget(
state: Pick<AppState, 'browserTabsByWorktree' | 'unifiedTabsByWorktree'>,
worktreeId: string,
@ -985,16 +994,8 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onOpenNewWorkspace(() => {
// Why: keep the global shortcut quiet on a fresh install, but allow
// both Git projects and plain folder projects to create workspaces.
const store = useAppStore.getState()
if (store.repos.length === 0) {
return
}
if (store.activeModal === 'new-workspace-composer') {
return
}
store.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(store))
openNewWorkspaceFromShortcut(store)
})
)

View File

@ -44,8 +44,11 @@ describe('new workspace create gates', () => {
).toBe(false)
})
it('still blocks quick create for missing form state and explicit setup choices', () => {
expect(getQuickComposerCreateDisabled({ ...readyInput, repoId: '' })).toBe(true)
it('keeps quick create clickable when no repo is selected so submit can validate inline', () => {
expect(getQuickComposerCreateDisabled({ ...readyInput, repoId: '' })).toBe(false)
})
it('still blocks quick create for other missing form state and explicit setup choices', () => {
expect(getQuickComposerCreateDisabled({ ...readyInput, workspaceSeedName: '' })).toBe(true)
expect(getQuickComposerCreateDisabled({ ...readyInput, creating: true })).toBe(true)
expect(

View File

@ -12,7 +12,6 @@ export type ComposerCreateGateInput = {
function hasBlockingCreateState(input: ComposerCreateGateInput): boolean {
return (
!input.repoId ||
!input.workspaceSeedName ||
input.creating ||
input.selectedRepoRequiresConnection ||

View File

@ -133,13 +133,13 @@ describe('worktree-palette-create-action', () => {
).toBe(CREATE_WORKTREE_ITEM_ID)
})
it('hides create when no git repos are available', () => {
it('shows create even when no project is available so the composer can guide setup', () => {
expect(
getWorktreePaletteCreateActionState({
canCreateWorktree: false,
query: 'new-workspace'
}).showCreateAction
).toBe(false)
).toBe(true)
})
it('hides create for an empty query', () => {

View File

@ -6,14 +6,13 @@ export type WorktreePaletteCreateActionState = {
}
export function getWorktreePaletteCreateActionState({
canCreateWorktree,
query
}: {
canCreateWorktree: boolean
query: string
}): WorktreePaletteCreateActionState {
const createWorktreeName = query.trim()
const showCreateAction = canCreateWorktree && createWorktreeName.length > 0
const showCreateAction = createWorktreeName.length > 0
return {
createWorktreeName,
showCreateAction