Always open workspace composer before projects (#4991)
This commit is contained in:
parent
ec083480da
commit
8ec5d599d0
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export type ComposerCreateGateInput = {
|
|||
|
||||
function hasBlockingCreateState(input: ComposerCreateGateInput): boolean {
|
||||
return (
|
||||
!input.repoId ||
|
||||
!input.workspaceSeedName ||
|
||||
input.creating ||
|
||||
input.selectedRepoRequiresConnection ||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue