From db21e8fe3064b372286bb4a334c2397d4168880d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 03:02:14 +0000 Subject: [PATCH] fix(ui): let New project choose a folder without a custom name Creating a project required typing a name before Choose folder would enable. An empty name is a valid choice: the catalog already names the project after the folder when projects.add is called without one. Leave the name field optional, keep Choose folder available, and pass a trimmed name through only when one was typed. Update en / zh-CN / zh-TW copy to match. Fixes #6047 --- .../__tests__/project-add-restore.test.ts | 29 +++++++++ .../use-session-workspace-recovery.ts | 5 +- .../controller/use-task-entry-controller.ts | 16 ++--- .../task-entry/ui/task-entry-host.tsx | 2 +- .../task-entry/ui/task-entry-provider.tsx | 2 +- .../settings/projects-settings-page.tsx | 10 ++-- .../src/__tests__/new-project-dialog.test.tsx | 59 +++++++++++++++++-- packages/ui/src/conversation-copy.ts | 10 ++-- packages/ui/src/new-project-dialog.tsx | 31 +++++----- packages/ui/src/workspace-picker.tsx | 15 ++--- 10 files changed, 129 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/main/__tests__/project-add-restore.test.ts b/apps/desktop/src/main/__tests__/project-add-restore.test.ts index 1ef2b9b0920..cf6358bb2fa 100644 --- a/apps/desktop/src/main/__tests__/project-add-restore.test.ts +++ b/apps/desktop/src/main/__tests__/project-add-restore.test.ts @@ -150,6 +150,21 @@ async function submitNamedProject(repetitions = 1) { await submitProjectName(repetitions); } +async function submitBlankProject() { + await click('New project'); + const form = document.querySelector('#maka-new-project-form'); + assert.ok(form); + const button = document.querySelector( + 'button[type="submit"][form="maka-new-project-form"]', + ); + assert.ok(button, 'choose-folder stays available with an empty name'); + assert.equal(button.disabled, false); + await act(async () => { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + }); + await flushFrames(); +} + async function submitProjectName(repetitions = 1) { const input = document.querySelector('input'); assert.ok(input); @@ -525,6 +540,20 @@ test('remote registration rejects same-tick duplicate submissions without invali assert.deepEqual(accepted, [restored]); }); +test('Settings Add with a blank name still opens the folder picker', async () => { + const harness = installRenderer(); + const names: unknown[] = []; + bridge({ + add: async (_target: DesktopRuntimeHostRef, options?: { name?: string }) => { + names.push(options); + return { ok: true, project: restored }; + }, + }); + await harness.render(createElement(components.SettingsFixture)); + await submitBlankProject(); + assert.deepEqual(names, [undefined]); +}); + test('Settings Add stays single-flight while the folder picker is pending', async () => { const harness = installRenderer(); const addition = deferred<{ ok: true; project: ProjectRecord }>(); diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-session-workspace-recovery.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-session-workspace-recovery.ts index 78d19e5208c..5a1bb2cac36 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-session-workspace-recovery.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-session-workspace-recovery.ts @@ -43,7 +43,8 @@ export interface AddSessionWorkspaceInput { sessionId: string; profileId: string; host: TaskEntryHostRef; - name: string; + /** Custom name from the New project dialog; omitted to use the folder name. */ + name?: string; request?: SessionWorkspaceRecoveryRequest; } @@ -148,7 +149,7 @@ export function useSessionWorkspaceRecovery({ let projectId: string; if (adding) { const result = await resolveProjectRegistration({ - register: () => service.addProject(input.host, input.name), + register: () => service.addProject(input.host, input.name?.trim() || undefined), confirm: (onConfirm) => (confirm ?? toast.confirm)({ onConfirm, title: projectCopy.archivedProjectTitle, diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts index e5ac59c2881..abee7a05fe0 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -339,10 +339,11 @@ export function useTaskEntryController( const addProjectForHost = useCallback(async (host: ReadyTaskEntryHost, name?: string): Promise => { if (projectMutationPendingRef.current) return; + const customName = name?.trim() || undefined; if (host.capabilities.chooseHostDirectory) { directoryOpenerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; - setDirectoryHost(directoryHandoffForHost(host, name)); + setDirectoryHost(directoryHandoffForHost(host, customName)); return; } if (!host.capabilities.chooseClientDirectory) return; @@ -350,7 +351,7 @@ export function useTaskEntryController( setPending(true); try { const result = await resolveProjectRegistration({ - register: () => service.addProject({ profileId: host.profile.id, hostId: host.hostId }, name), + register: () => service.addProject({ profileId: host.profile.id, hostId: host.hostId }, customName), confirm: (onConfirm) => (input.confirm ?? toast.confirm)({ onConfirm, title: copy.archivedProjectTitle, @@ -521,7 +522,7 @@ export function useTaskEntryController( selectedProjectId: groupSelectedProjectId, onSelectProject: (projectId: string) => selectProject(host, projectId), ...(host.capabilities.chooseClientDirectory || host.capabilities.chooseHostDirectory - ? { onAdd: (name: string) => void addProjectForHost(host, name) } + ? { onAdd: (name?: string) => void addProjectForHost(host, name) } : {}), ...(host.capabilities.chooseClientDirectory ? { onRelink: (projectId: string) => void relinkProject(host, projectId) } @@ -657,10 +658,11 @@ export function useTaskEntryController( const addSelectedProject = useCallback( (name?: string) => { // The workspace-readiness notice hands this straight to an onClick, so a - // MouseEvent can arrive where a name is expected. Only a real string is a - // name; anything else means "no name given". + // MouseEvent can arrive where a name is expected. Only a non-empty string + // is a custom name; anything else means "use the folder name". if (selectedHost) { - void addProjectForHost(selectedHost, typeof name === 'string' ? name : undefined); + const customName = typeof name === 'string' ? name.trim() : ''; + void addProjectForHost(selectedHost, customName || undefined); } }, [addProjectForHost, selectedHost], @@ -685,7 +687,7 @@ export function useTaskEntryController( const openNewProject = useCallback(() => setNewProjectOpen(true), []); const newProjectDialog = useMemo(() => newProjectOpen ? { close: () => setNewProjectOpen(false), - submit: (name: string) => addSelectedProject(name), + submit: (name?: string) => addSelectedProject(name), } : undefined, [newProjectOpen, addSelectedProject]); const openFolder = useCallback(async ( folder: TaskEntryFolder, diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx index 4db37a4cd90..aef2ef22a08 100644 --- a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx +++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx @@ -24,7 +24,7 @@ import type { TaskEntryHostRef } from '../ports.js'; import { useTaskEntryHostModel } from './task-entry-provider.js'; export interface TaskEntryHostModel { - newProjectDialog?: { close(): void; submit(name: string): void }; + newProjectDialog?: { close(): void; submit(name?: string): void }; directoryHost?: TaskEntryHostRef & { readonly name?: string }; directoryOpener?: HTMLElement | null; closeDirectoryPicker(): void; diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx index d41a94aac4e..15e39e12010 100644 --- a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx +++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx @@ -427,7 +427,7 @@ export function TaskEntryWorkspacePickerConsumer({ }), onAdd: activeSession.profileKind === 'local' && activeGroup.hostId - ? (name: string) => void owner.commands.addSessionWorkspace({ + ? (name?: string) => void owner.commands.addSessionWorkspace({ sessionId: activeSession.id, profileId: activeSession.profileId, host: { profileId: activeGroup.id, hostId: activeGroup.hostId! }, diff --git a/apps/desktop/src/renderer/settings/projects-settings-page.tsx b/apps/desktop/src/renderer/settings/projects-settings-page.tsx index 7777e3384dc..cc640215fb8 100644 --- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx @@ -108,15 +108,15 @@ export function ProjectsSettingsPage(props: { }, [host, mountedRef, props.runtimeHostTargetVerified]); /** - * Create a project from the name the New project dialog collected. The add - * call still owns the folder picker, so a cancelled picker simply leaves the - * list unchanged; the name rides along and the project is registered under it. + * Create a project, opening the folder picker. A typed name is applied in the + * same add; a blank name leaves the folder-derived catalog name alone. */ - async function addNamedProject(name: string, registration: ProjectRegistration) { + async function addNamedProject(name: string | undefined, registration: ProjectRegistration) { if (!host || !props.runtimeHostTargetVerified) return; + const trimmed = name?.trim(); await runRowAction('add', async () => { const result = await registration.register( - () => window.maka.projects.add(host, { name }), + () => window.maka.projects.add(host, trimmed ? { name: trimmed } : undefined), () => mountedRef.current, ); // Cancelled or invalidated registrations must not trigger a refresh. diff --git a/packages/ui/src/__tests__/new-project-dialog.test.tsx b/packages/ui/src/__tests__/new-project-dialog.test.tsx index 5f5c8e66fc0..f027f75f187 100644 --- a/packages/ui/src/__tests__/new-project-dialog.test.tsx +++ b/packages/ui/src/__tests__/new-project-dialog.test.tsx @@ -20,8 +20,8 @@ /** * Submitting the New project dialog must be the dialog's business, never the * browser's. The picker lives inside the composer's `
`, so a submit that - * escaped this component would navigate the window — the failure these cases - * pin down is `defaultPrevented`, not the name that travels. + * escaped this component would navigate the window. An empty name is a valid + * submit ("use the folder name"), not a reason to swallow the event. */ import assert from 'node:assert/strict'; @@ -82,7 +82,7 @@ async function mountDialog() { installDomStubs(window); Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); - const submitted: string[] = []; + const submitted: Array = []; let openChanges = 0; const container = document.querySelector('#root'); assert.ok(container); @@ -103,8 +103,24 @@ async function mountDialog() { ); return { + document, submitted, openChanges: () => openChanges, + type: async (value: string) => { + const input = document.querySelector('input'); + assert.ok(input, 'no name field'); + await act(() => { + input.value = value; + // linkedom does not run React's native input listener; call it the same + // way `project-add-restore` types into this field. + const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey, 'no react props on the name field'); + const props = (input as unknown as Record)[propsKey] as { + onChange: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + }; + props.onChange({ target: input, defaultPrevented: false }); + }); + }, submit: () => { const form = document.querySelector('#maka-new-project-form'); assert.ok(form, 'no form'); @@ -135,16 +151,49 @@ test('a submit belongs to the dialog and never reaches the browser', async () => } }); -test('an empty name submits nothing at all', async () => { +test('an empty name still opens the folder picker', async () => { const dialog = await mountDialog(); try { + const button = dialog.document.querySelector( + 'button[type="submit"][form="maka-new-project-form"]', + ); + assert.ok(button, 'no choose-folder button'); + assert.equal(button.disabled, false); + let event!: Event; await act(() => { event = dialog.submit(); }); assert.equal(event.defaultPrevented, true); - assert.deepEqual(dialog.submitted, []); + assert.equal(dialog.openChanges(), 1); + assert.deepEqual(dialog.submitted, [undefined]); + } finally { + await dialog.dispose(); + } +}); + +test('a typed name is trimmed and passed through', async () => { + const dialog = await mountDialog(); + try { + await dialog.type(' Lab notes '); + await act(() => { + dialog.submit(); + }); + assert.deepEqual(dialog.submitted, ['Lab notes']); + } finally { + await dialog.dispose(); + } +}); + +test('a whitespace-only name is not a custom name', async () => { + const dialog = await mountDialog(); + try { + await dialog.type(' '); + await act(() => { + dialog.submit(); + }); + assert.deepEqual(dialog.submitted, [undefined]); } finally { await dialog.dispose(); } diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index ba48b34b2b5..b5b1ef22a22 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -245,11 +245,11 @@ export interface ConversationCopy { workspace: { choose: string; current: string; - /** The picker's create action; the dialog that follows names the project. */ + /** The picker's create action; the dialog that follows may override the folder name. */ newProject: string; /** Header of the New project dialog. */ newProjectTitle: string; - /** One line under the header: name first, folder next. */ + /** One line under the name field: a custom name is optional. */ newProjectDescription: string; newProjectNameLabel: string; /** The dialog's submit, which opens the folder picker. */ @@ -512,7 +512,7 @@ const CONVERSATION_COPY = { forms: { keyboardHint: '1–9 选择 · ↑↓ 切换 · Enter 确认 · Esc 取消', requester: (name) => `由 ${name} 请求`, requesterWithSource: (name, source) => `由 ${name} 请求 · ${source}`, required: '必填', optional: '选填', include: (label) => `提供:${label}`, enabled: (label) => `启用:${label}`, enterValue: '输入内容', enterNumber: '输入数字', constraintSeparator: ';', lengthConstraint: (minimum, maximum) => minimum === undefined ? `最多 ${maximum} 个字符` : maximum === undefined ? `至少 ${minimum} 个字符` : `长度 ${minimum}–${maximum} 个字符`, numberConstraint: (minimum, maximum) => minimum === undefined ? `最大值 ${maximum}` : maximum === undefined ? `最小值 ${minimum}` : `范围 ${minimum}–${maximum}`, itemConstraint: (minimum, maximum) => minimum === undefined ? `最多选择 ${maximum} 项` : maximum === undefined ? `至少选择 ${minimum} 项` : `选择 ${minimum}–${maximum} 项`, formatConstraint: { email: '格式:email', uri: '格式:URI', date: '格式:date(YYYY-MM-DD)', 'date-time': '格式:date-time(RFC 3339)' }, invalid: '请提供符合要求的值。', cancel: '取消', decline: '拒绝', accept: '提交', submitting: '正在提交…' }, mentions: { noFiles: '未找到文件', noFilesOrSessions: '未找到文件或会话', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', filesAndSessionsAriaLabel: '工作区文件和会话', sessionsGroup: '会话', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…', sessionReferenceUnavailableTitle: '无法引用会话', sessionReferenceUnavailableDetail: '该会话已不可用,请刷新任务列表后重试。', sessionReferenceEmptyTitle: '会话没有可引用内容', sessionReferenceEmptyDetail: '只会引用用户和助手文本;工具调用等内部记录不会注入当前任务。', sessionReferenceReadFailedTitle: '读取会话失败', sessionReferenceReadFailedDetail: '无法读取该会话的快照,请稍后重试。', sessionReferenceLimitDetail: '引用总数最多为 16,请移除部分引用后重试。' }, workspace: { - choose: '选择项目', current: '当前项目', newProject: '新建项目', newProjectTitle: '新建项目', newProjectDescription: '先给项目起个名字,再选择它所在的文件夹。', newProjectNameLabel: '项目名称', newProjectSubmit: '选择文件夹', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用', + choose: '选择项目', current: '当前项目', newProject: '新建项目', newProjectTitle: '新建项目', newProjectDescription: '可留空以使用文件夹名称,也可自行填写。', newProjectNameLabel: '项目名称', newProjectSubmit: '选择文件夹', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用', chooseTitle: (branch) => branch ? `选择项目 · ${branch}` : '选择项目', chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`, }, @@ -635,7 +635,7 @@ const CONVERSATION_COPY = { forms: { keyboardHint: '1–9 選擇 · ↑↓ 切換 · Enter 確認 · Esc 取消', requester: (name) => `由 ${name} 請求`, requesterWithSource: (name, source) => `由 ${name} 請求 · ${source}`, required: '必填', optional: '選填', include: (label) => `提供:${label}`, enabled: (label) => `啟用:${label}`, enterValue: '輸入內容', enterNumber: '輸入數字', constraintSeparator: ';', lengthConstraint: (minimum, maximum) => minimum === undefined ? `最多 ${maximum} 個字元` : maximum === undefined ? `至少 ${minimum} 個字元` : `長度 ${minimum}–${maximum} 個字元`, numberConstraint: (minimum, maximum) => minimum === undefined ? `最大值 ${maximum}` : maximum === undefined ? `最小值 ${minimum}` : `範圍 ${minimum}–${maximum}`, itemConstraint: (minimum, maximum) => minimum === undefined ? `最多選取 ${maximum} 項` : maximum === undefined ? `至少選取 ${minimum} 項` : `選取 ${minimum}–${maximum} 項`, formatConstraint: { email: '格式:email', uri: '格式:URI', date: '格式:date(YYYY-MM-DD)', 'date-time': '格式:date-time(RFC 3339)' }, invalid: '請提供符合要求的值。', cancel: '取消', decline: '拒絕', accept: '提交', submitting: '正在提交…' }, mentions: { noFiles: '未找到檔案', noFilesOrSessions: '未找到檔案或作業階段', noSkills: '暫無技能', noCommandsOrSkills: '沒有符合的命令或技能', filesAriaLabel: '工作區檔案', filesAndSessionsAriaLabel: '工作區檔案和作業階段', sessionsGroup: '作業階段', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '載入中…', sessionReferenceUnavailableTitle: '無法引用作業階段', sessionReferenceUnavailableDetail: '這個作業階段已無法使用,請重新整理任務清單後再試一次。', sessionReferenceEmptyTitle: '沒有可引用的內容', sessionReferenceEmptyDetail: '只會引用使用者和助理的文字;工具呼叫等內部記錄不會注入目前任務。', sessionReferenceReadFailedTitle: '讀取作業階段失敗', sessionReferenceReadFailedDetail: '無法讀取這個作業階段的快照,請稍後再試。', sessionReferenceLimitDetail: '引用總數最多為 16,請移除部分引用後再試。' }, workspace: { - choose: '選擇專案', current: '目前專案', newProject: '新增專案', newProjectTitle: '新增專案', newProjectDescription: '先為專案命名,再選擇它所在的資料夾。', newProjectNameLabel: '專案名稱', newProjectSubmit: '選擇資料夾', manageProjects: '管理專案', noProject: '無專案', relink: '重新定位', unavailable: '不可用', + choose: '選擇專案', current: '目前專案', newProject: '新增專案', newProjectTitle: '新增專案', newProjectDescription: '可留空以使用資料夾名稱,也可自行填寫。', newProjectNameLabel: '專案名稱', newProjectSubmit: '選擇資料夾', manageProjects: '管理專案', noProject: '無專案', relink: '重新定位', unavailable: '不可用', chooseTitle: (branch) => branch ? `選擇專案 · ${branch}` : '選擇專案', chooseAriaLabel: (label, branch) => branch ? `選擇專案:${label},目前分支 ${branch}` : `選擇專案:${label}`, }, @@ -755,7 +755,7 @@ const CONVERSATION_COPY = { forms: { keyboardHint: '1–9 select · ↑↓ navigate · Enter confirm · Esc cancel', requester: (name) => `Requested by ${name}`, requesterWithSource: (name, source) => `Requested by ${name} · ${source}`, required: 'Required', optional: 'Optional', include: (label) => `Provide ${label}`, enabled: (label) => `Enable ${label}`, enterValue: 'Enter a value', enterNumber: 'Enter a number', constraintSeparator: ' · ', lengthConstraint: (minimum, maximum) => minimum === undefined ? `At most ${maximum} characters` : maximum === undefined ? `At least ${minimum} characters` : `${minimum}–${maximum} characters`, numberConstraint: (minimum, maximum) => minimum === undefined ? `Maximum ${maximum}` : maximum === undefined ? `Minimum ${minimum}` : `Range ${minimum}–${maximum}`, itemConstraint: (minimum, maximum) => minimum === undefined ? `Select at most ${maximum}` : maximum === undefined ? `Select at least ${minimum}` : `Select ${minimum}–${maximum}`, formatConstraint: { email: 'Format: email', uri: 'Format: URI', date: 'Format: date (YYYY-MM-DD)', 'date-time': 'Format: date-time (RFC 3339)' }, invalid: 'Provide a value that meets the requirements.', cancel: 'Cancel', decline: 'Decline', accept: 'Submit', submitting: 'Submitting…' }, mentions: { noFiles: 'No files found', noFilesOrSessions: 'No files or sessions found', noSkills: 'No skills available', noCommandsOrSkills: 'No matching commands or skills', filesAriaLabel: 'Workspace files', filesAndSessionsAriaLabel: 'Workspace files and sessions', sessionsGroup: 'Sessions', skillsAriaLabel: 'Skills', commandsAndSkillsAriaLabel: 'Commands and skills', commandsGroup: 'Commands', skillsGroup: 'Skills', loading: 'Loading…', sessionReferenceUnavailableTitle: 'Session reference unavailable', sessionReferenceUnavailableDetail: 'This session is no longer available. Refresh the task list and try again.', sessionReferenceEmptyTitle: 'No referenceable content', sessionReferenceEmptyDetail: 'Only user and assistant text is shared; tool calls and internal records stay out of the current task.', sessionReferenceReadFailedTitle: 'Could not read session', sessionReferenceReadFailedDetail: 'The session snapshot could not be read. Try again later.', sessionReferenceLimitDetail: 'A message can contain at most 16 quotes. Remove a quote and try again.' }, workspace: { - choose: 'Choose project', current: 'Current project', newProject: 'New project', newProjectTitle: 'New project', newProjectDescription: 'Name the project, then choose the folder it lives in.', newProjectNameLabel: 'Project name', newProjectSubmit: 'Choose folder', manageProjects: 'Manage projects', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable', + choose: 'Choose project', current: 'Current project', newProject: 'New project', newProjectTitle: 'New project', newProjectDescription: 'Leave blank to use the folder name, or type a custom name.', newProjectNameLabel: 'Project name', newProjectSubmit: 'Choose folder', manageProjects: 'Manage projects', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable', chooseTitle: (branch) => branch ? `Choose project · ${branch}` : 'Choose project', chooseAriaLabel: (label, branch) => branch ? `Choose project: ${label}, current branch ${branch}` : `Choose project: ${label}`, }, diff --git a/packages/ui/src/new-project-dialog.tsx b/packages/ui/src/new-project-dialog.tsx index b206cf6155d..81dad9b4e04 100644 --- a/packages/ui/src/new-project-dialog.tsx +++ b/packages/ui/src/new-project-dialog.tsx @@ -25,22 +25,23 @@ import { getConversationCopy } from './conversation-copy.js'; import { useUiLocale } from './locale-context.js'; /** - * Naming a project before its folder is chosen. + * An optional custom name, then the folder the project lives in. * * A project is directory-backed, so this dialog cannot create one on its own — - * it collects the one thing the folder picker cannot (a name) and hands it to - * the caller, which then asks for the directory. The name therefore leads: by - * the time the picker opens, the user has already said what this is, and the - * project is registered under it rather than under the folder's basename. + * it collects a name only when the user typed one, and hands that through to + * the caller, which then asks for the directory. An empty field is a valid + * choice: the catalog names the project after the folder. A typed name is + * applied in the same add, so the project is registered under it rather than + * under the folder's basename. * * One button, no 取消: the header's close control and Escape are already two - * ways out, the convention `SessionRenameDialog` set. The submit is disabled - * while the field is empty, so there is nothing to validate on the way through. + * ways out, the convention `SessionRenameDialog` set. Choose folder is never + * gated on the name; leaving it blank is how you take the folder name. */ export function NewProjectDialog(props: { onOpenChange(open: boolean): void; - /** The typed name, trimmed and non-empty; the caller opens the folder picker. */ - onSubmit(name: string): void; + /** Trimmed custom name, or omitted when the field was left blank. */ + onSubmit(name?: string): void; }) { const copy = getConversationCopy(useUiLocale()).workspace; const [name, setName] = useState(''); @@ -62,16 +63,13 @@ export function NewProjectDialog(props: { return () => window.cancelAnimationFrame(frame); }, []); - const trimmed = name.trim(); - function submit(event: FormEvent) { event.preventDefault(); props.onOpenChange(false); - // Escape and the header's close control are the two ways out; an empty field - // cannot reach here, because the only submit affordance is disabled while it - // is empty. The guard stands for the browsers that run implicit submission - // through a disabled default button anyway. - if (trimmed) props.onSubmit(trimmed); + // A blank (or whitespace-only) name is not a name: omit it so the catalog's + // folder-basename fallback applies. A typed name still rides along. + const trimmed = name.trim(); + props.onSubmit(trimmed || undefined); } return ( @@ -87,7 +85,6 @@ export function NewProjectDialog(props: { variant="primary" type="submit" form="maka-new-project-form" - isDisabled={!trimmed} label={copy.newProjectSubmit} /> diff --git a/packages/ui/src/workspace-picker.tsx b/packages/ui/src/workspace-picker.tsx index 90329339727..79cfad3b37b 100644 --- a/packages/ui/src/workspace-picker.tsx +++ b/packages/ui/src/workspace-picker.tsx @@ -38,11 +38,12 @@ export interface WorkspacePickerGroup { selectedProjectId?: string | null; onSelectProject?(projectId: string): void; /** - * Create a project on this host. The picker collects the name first (see - * `NewProjectDialog`) and passes it here; the handler then asks for the - * directory and registers the project under that name. + * Create a project on this host. The picker collects an optional custom name + * (see `NewProjectDialog`) and passes it here when one was typed; the handler + * then asks for the directory. An omitted name lets the catalog use the + * folder basename. */ - onAdd?(name: string): void; + onAdd?(name?: string): void; onManage?(): void; onRelink?(projectId: string): void; onSelectNoProject?(): void; @@ -68,9 +69,9 @@ export function WorkspacePicker({ workspacePicker: picker }: { }) { const copy = getConversationCopy(useUiLocale()).workspace; const locked = picker.pending === true; - // Which host's New project dialog is open. The picker names the project, then - // the caller's `onAdd` opens the folder picker — so the name is collected - // before the directory step, not after it. + // Which host's New project dialog is open. The picker may collect a custom + // name, then the caller's `onAdd` opens the folder picker. An empty name is + // "use the folder name", not a reason to block the picker. const [newProjectGroupId, setNewProjectGroupId] = useState(null); const newProjectGroup = picker.groups.find((group) => group.id === newProjectGroupId);