Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 184 additions & 14 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@
<button class="new-thread-folder-action new-thread-folder-action-primary" type="button" @click="onOpenExistingFolder">
{{ t('Select folder') }}
</button>
<button class="new-thread-folder-action" type="button" @click="onCreateProject">
<button class="new-thread-folder-action" type="button" @click="onOpenProjectSetupModal">
{{ t('Create Project') }}
</button>
</div>
Expand Down Expand Up @@ -719,6 +719,90 @@
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="isProjectSetupModalOpen" class="new-thread-open-folder-overlay" @click.self="onCloseProjectSetupModal">
<div class="new-thread-project-modal" role="dialog" aria-modal="true" :aria-label="t('Create or clone project')" @keydown.esc.prevent="onCloseProjectSetupModal">
<div class="new-thread-open-folder-header">
<p class="new-thread-open-folder-title">{{ t('Create or clone project') }}</p>
<button class="new-thread-open-folder-close" type="button" :disabled="isProjectSetupSubmitting" @click="onCloseProjectSetupModal">
{{ t('Cancel') }}
</button>
</div>
<div class="new-thread-project-mode-tabs" role="tablist" :aria-label="t('Project source')">
<button
class="new-thread-project-mode-tab"
:class="{ 'is-active': projectSetupMode === 'create' }"
type="button"
role="tab"
:aria-selected="projectSetupMode === 'create'"
:disabled="isProjectSetupSubmitting"
@click="projectSetupMode = 'create'"
>
{{ t('New project') }}
</button>
<button
class="new-thread-project-mode-tab"
:class="{ 'is-active': projectSetupMode === 'clone' }"
type="button"
role="tab"
:aria-selected="projectSetupMode === 'clone'"
:disabled="isProjectSetupSubmitting"
@click="projectSetupMode = 'clone'"
>
{{ t('Clone from GitHub') }}
</button>
</div>
<label class="new-thread-project-field">
<span class="new-thread-open-folder-label">{{ t('Destination folder') }}</span>
<input
v-model="projectSetupBaseDir"
class="new-thread-open-folder-path"
type="text"
:disabled="isProjectSetupSubmitting"
:placeholder="t('Destination folder')"
/>
</label>
<label v-if="projectSetupMode === 'create'" class="new-thread-project-field">
<span class="new-thread-open-folder-label">{{ t('Project name') }}</span>
<input
ref="projectSetupPrimaryInputRef"
v-model="projectNameDraft"
class="new-thread-open-folder-create-input"
type="text"
:disabled="isProjectSetupSubmitting"
:placeholder="t('Project name')"
@keydown.enter.prevent="onSubmitProjectSetup"
/>
</label>
<label v-else class="new-thread-project-field">
<span class="new-thread-open-folder-label">{{ t('GitHub repository URL') }}</span>
<input
ref="projectSetupPrimaryInputRef"
v-model="githubCloneUrlDraft"
class="new-thread-open-folder-create-input"
type="url"
:disabled="isProjectSetupSubmitting"
placeholder="https://github.com/owner/repo"
@keydown.enter.prevent="onSubmitProjectSetup"
/>
</label>
<p v-if="projectSetupError" class="new-thread-open-folder-error">{{ projectSetupError }}</p>
<div class="new-thread-project-modal-actions">
<button class="new-thread-folder-action" type="button" :disabled="isProjectSetupSubmitting" @click="onCloseProjectSetupModal">
{{ t('Cancel') }}
</button>
<button
class="new-thread-folder-action new-thread-folder-action-primary"
type="button"
:disabled="!canSubmitProjectSetup || isProjectSetupSubmitting"
@click="onSubmitProjectSetup"
>
{{ projectSetupSubmitLabel }}
</button>
</div>
</div>
</div>
</Teleport>
<ComposerRuntimeDropdown
v-if="isNewThreadCwdGitRepo"
class="new-thread-runtime-dropdown"
Expand Down Expand Up @@ -970,6 +1054,7 @@ import { useMobile } from './composables/useMobile'
import { useUiLanguage } from './composables/useUiLanguage'
import {
checkoutGitBranch,
cloneGithubRepository,
configureTelegramBot,
createPermanentWorktree,
createWorktree,
Expand Down Expand Up @@ -1359,6 +1444,14 @@ const isCreateFolderOpen = ref(false)
const createFolderDraft = ref('')
const createFolderError = ref('')
const isCreatingFolder = ref(false)
const isProjectSetupModalOpen = ref(false)
const projectSetupMode = ref<'create' | 'clone'>('create')
const projectSetupBaseDir = ref('')
const projectNameDraft = ref('')
const githubCloneUrlDraft = ref('')
const projectSetupError = ref('')
const isProjectSetupSubmitting = ref(false)
const projectSetupPrimaryInputRef = ref<HTMLInputElement | null>(null)
const isExistingFolderPickerOpen = ref(false)
const existingFolderPathInputRef = ref<HTMLInputElement | null>(null)
const existingFolderFilterInputRef = ref<HTMLInputElement | null>(null)
Expand Down Expand Up @@ -1666,6 +1759,18 @@ const isCreateFolderNameValid = computed(() => {
const canCreateFolder = computed(() => {
return isCreateFolderNameValid.value && createFolderParentPath.value.trim().length > 0 && !existingFolderError.value
})
const isProjectNameDraftValid = computed(() => {
const draft = projectNameDraft.value.trim()
if (!draft) return false
if (draft === '.' || draft === '..') return false
return !/[\\/]/u.test(draft)
})
const canSubmitProjectSetup = computed(() => {
const baseDir = projectSetupBaseDir.value.trim()
if (!baseDir) return false
if (projectSetupMode.value === 'create') return isProjectNameDraftValid.value
return githubCloneUrlDraft.value.trim().length > 0
})
const resolvedExistingFolderPath = computed(() => {
const draftedPath = normalizePathForUi(existingFolderPathDraft.value).trim()
if (draftedPath) return draftedPath
Expand All @@ -1675,6 +1780,12 @@ const createFolderSubmitLabel = computed(() => {
if (isCreatingFolder.value) return 'Creating…'
return 'Create'
})
const projectSetupSubmitLabel = computed(() => {
if (isProjectSetupSubmitting.value) {
return projectSetupMode.value === 'clone' ? t('Cloning…') : t('Creating…')
}
return projectSetupMode.value === 'clone' ? t('Clone repository') : t('Create project')
})
const canBrowseExistingFolderParent = computed(() => {
const current = existingFolderBrowsePath.value.trim()
const parent = existingFolderParentPath.value.trim()
Expand Down Expand Up @@ -3019,35 +3130,70 @@ function loadThreadBranchCommits(branch: string): void {
})
}

async function onCreateProject(): Promise<void> {
async function onOpenProjectSetupModal(): Promise<void> {
const baseDir = await resolveProjectBaseDirectory()
if (!baseDir) return

await refreshDefaultProjectName()
const suggestedName = defaultNewProjectName.value.trim() || 'New Project (1)'
const projectName = window.prompt(`Create project in ${baseDir}`, suggestedName)
if (projectName === null) return
projectSetupBaseDir.value = baseDir
projectNameDraft.value = defaultNewProjectName.value.trim() || 'New Project (1)'
githubCloneUrlDraft.value = ''
projectSetupError.value = ''
projectSetupMode.value = 'create'
isProjectSetupModalOpen.value = true
void nextTick(() => projectSetupPrimaryInputRef.value?.focus())
}

const normalizedProjectName = projectName.trim()
if (!normalizedProjectName) return
function onCloseProjectSetupModal(): void {
if (isProjectSetupSubmitting.value) return
isProjectSetupModalOpen.value = false
projectSetupError.value = ''
}

async function createProjectFromSetupModal(): Promise<string> {
const baseDir = projectSetupBaseDir.value.trim()
const normalizedProjectName = projectNameDraft.value.trim()
if (!isProjectNameDraftValid.value) {
throw new Error('Enter a single project folder name.')
}
const targetPath = normalizeAbsolutePath(joinPath(baseDir, normalizedProjectName))
if (!targetPath) return
if (!targetPath) return ''

return openProjectRoot(targetPath, {
createIfMissing: true,
label: '',
})
}

async function cloneGithubRepositoryFromSetupModal(): Promise<string> {
const baseDir = projectSetupBaseDir.value.trim()
const normalizedRepoUrl = githubCloneUrlDraft.value.trim()
if (!normalizedRepoUrl) return ''

return cloneGithubRepository(normalizedRepoUrl, baseDir)
}

async function onSubmitProjectSetup(): Promise<void> {
if (!canSubmitProjectSetup.value || isProjectSetupSubmitting.value) return

projectSetupError.value = ''
isProjectSetupSubmitting.value = true
try {
const normalizedPath = await openProjectRoot(targetPath, {
createIfMissing: true,
label: '',
})
const normalizedPath =
projectSetupMode.value === 'clone'
? await cloneGithubRepositoryFromSetupModal()
: await createProjectFromSetupModal()
if (!normalizedPath) return

newThreadCwd.value = normalizedPath
pinProjectToTop(getProjectOrderNameForPath(normalizedPath))
await loadWorkspaceRootOptionsState()
await refreshDefaultProjectName()
isProjectSetupModalOpen.value = false
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create the project.'
window.alert(message)
projectSetupError.value = error instanceof Error ? error.message : 'Failed to create or clone project.'
} finally {
isProjectSetupSubmitting.value = false
}
}

Expand Down Expand Up @@ -4562,6 +4708,10 @@ async function loadWorktreeBranches(sourceCwd: string): Promise<void> {
@apply flex w-full max-w-3xl max-h-[90vh] flex-col gap-2 overflow-y-auto rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-xl;
}

.new-thread-project-modal {
@apply flex w-full max-w-xl max-h-[90vh] flex-col gap-3 overflow-y-auto rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-xl;
}

.new-thread-open-folder-header {
@apply flex items-center justify-between gap-3;
}
Expand Down Expand Up @@ -4590,6 +4740,26 @@ async function loadWorktreeBranches(sourceCwd: string): Promise<void> {
@apply flex flex-wrap items-center gap-2;
}

.new-thread-project-mode-tabs {
@apply grid grid-cols-2 rounded-xl border border-zinc-200 bg-zinc-50 p-1;
}

.new-thread-project-mode-tab {
@apply inline-flex h-9 items-center justify-center rounded-lg border-0 bg-transparent px-3 text-sm font-medium text-zinc-600 transition hover:bg-white hover:text-zinc-900 disabled:cursor-default disabled:opacity-60;
}

.new-thread-project-mode-tab.is-active {
@apply bg-white text-zinc-950 shadow-sm;
}

.new-thread-project-field {
@apply flex flex-col gap-1.5;
}

.new-thread-project-modal-actions {
@apply mt-1 flex flex-wrap justify-end gap-2;
}

.new-thread-open-folder-toggle {
@apply inline-flex items-center gap-2 text-sm text-zinc-600;
}
Expand Down
22 changes: 22 additions & 0 deletions src/api/codexGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2807,6 +2807,28 @@ export async function createLocalDirectory(path: string): Promise<string> {
return typeof data.path === 'string' ? normalizePathForUi(data.path) : ''
}

export async function cloneGithubRepository(url: string, basePath: string): Promise<string> {
const response = await fetch('/codex-api/github-clone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, basePath }),
})
const payload = await readJsonResponse(response)
if (!response.ok) {
const message = getErrorMessageFromPayload(payload, 'Failed to clone GitHub repository')
throw new Error(message)
}
const record =
payload && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: {}
const data =
record.data && typeof record.data === 'object' && !Array.isArray(record.data)
? (record.data as Record<string, unknown>)
: {}
return typeof data.path === 'string' ? normalizePathForUi(data.path) : ''
}
Comment on lines +2810 to +2830
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Workspace roots cache stale 🐞 Bug ≡ Correctness

After a successful GitHub clone, the server persists the cloned folder as a workspace root, but the
client-side getWorkspaceRootsState() cache is not invalidated, so the UI can reload/persist an
out-of-date roots list and omit (or overwrite) the new root. This can make the newly-cloned project
not show up in the folder selector or have incorrect ordering until a full refresh.
Agent Prompt
### Issue description
`cloneGithubRepository()` persists a new workspace root on the server, but the browser keeps using a cached `getWorkspaceRootsState()` result, so immediately after cloning the UI can operate on stale workspace-roots state (missing the new root) and even persist that stale state back.

### Issue Context
`openProjectRoot()` explicitly calls `invalidateWorkspaceRootsStateCache()`, but `cloneGithubRepository()` does not.

### Fix Focus Areas
- src/api/codexGateway.ts[2810-2830]
- src/api/codexGateway.ts[2760-2785]

### Suggested fix
- After a successful clone (once a valid `data.path` is returned), call `invalidateWorkspaceRootsStateCache()` (mirroring `openProjectRoot()`).
- Optionally, consider returning additional data from the clone endpoint (e.g., updated workspace-roots state) and updating the cache directly, but invalidation alone should fix the immediate correctness bug.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


export async function createProjectlessThreadDirectory(prompt?: string): Promise<{ cwd: string; outputDirectory: string; workspaceRoot: string }> {
const response = await fetch('/codex-api/projectless-thread-cwd', {
method: 'POST',
Expand Down
12 changes: 12 additions & 0 deletions src/composables/useUiLanguage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ const zhCN: Record<string, string> = {
'Selected folder': '已选文件夹',
'Select folder': '选择文件夹',
'Create Project': '创建项目',
'Create or clone': '创建或克隆',
'Create or clone project': '创建或克隆项目',
'Project source': '项目来源',
'New project': '新项目',
'Destination folder': '目标文件夹',
'New project name': '新项目名称',
'GitHub repository URL': 'GitHub 仓库 URL',
'Create project': '创建项目',
'Creating…': '创建中…',
'Clone repository': '克隆仓库',
'Clone from GitHub': '从 GitHub 克隆',
'Cloning…': '克隆中…',
'Current folder': '当前文件夹',
'Unavailable': '不可用',
'Opening…': '打开中…',
Expand Down
Loading