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..c8295a5259f 100644 --- a/apps/desktop/src/main/__tests__/project-add-restore.test.ts +++ b/apps/desktop/src/main/__tests__/project-add-restore.test.ts @@ -505,6 +505,28 @@ for (const invalidation of ['host', 'verification', 'unmount'] as const) { }); } +test('Settings submits a blank project name as no name', async () => { + const harness = installRenderer(); + const received: unknown[] = []; + bridge({ + add: async (_target: DesktopRuntimeHostRef, options?: { name?: string }) => { + received.push(options); + return { ok: true, project: restored }; + }, + }); + await harness.render(createElement(components.SettingsFixture)); + await click('New project'); + const form = document.querySelector('#maka-new-project-form'); + assert.ok(form, document.body.textContent); + // Submit without typing: the field is optional now, so the picker still opens. + await act(async () => { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + }); + await flushFrames(); + assert.deepEqual(received, [undefined]); + assert.equal(document.querySelector('#maka-new-project-form'), null); +}); + test('remote registration rejects same-tick duplicate submissions without invalidating the first request', async () => { const harness = installRenderer(); const registration = deferred(); 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..04f9a428e14 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; + /** The optional name from the New project dialog; absent means the folder name. */ + name?: string; request?: SessionWorkspaceRecoveryRequest; } 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..a752e4298ae 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 @@ -521,7 +521,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) } @@ -685,7 +685,8 @@ export function useTaskEntryController( const openNewProject = useCallback(() => setNewProjectOpen(true), []); const newProjectDialog = useMemo(() => newProjectOpen ? { close: () => setNewProjectOpen(false), - submit: (name: string) => addSelectedProject(name), + // An empty submit is "no name": the folder's basename will be used. + 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..b176a3c5b4f 100644 --- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx @@ -108,15 +108,16 @@ 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 from the optional name the New project dialog collected. + * The add call still owns the folder picker, so a cancelled picker simply + * leaves the list unchanged; a typed name rides along, and a blank one lets + * the catalog name the project after its folder. */ - async function addNamedProject(name: string, registration: ProjectRegistration) { + async function addNamedProject(name: string | undefined, registration: ProjectRegistration) { if (!host || !props.runtimeHostTargetVerified) return; await runRowAction('add', async () => { const result = await registration.register( - () => window.maka.projects.add(host, { name }), + () => window.maka.projects.add(host, name ? { name } : undefined), () => mountedRef.current, ); // Cancelled or invalidated registrations must not trigger a refresh. @@ -292,8 +293,8 @@ export function ProjectsSettingsPage(props: { if (props.runtimeHostTargetVerified) setProjectDialog('directory'); } : () => { - // The dialog names the project first; the folder picker it - // opens is the same one, just reached one step later. + // The dialog collects an optional name; the folder picker + // it opens is the same one, just reached one step later. if (props.runtimeHostTargetVerified) setProjectDialog('new'); }} /> diff --git a/packages/ui/src/__tests__/new-project-dialog.test.tsx b/packages/ui/src/__tests__/new-project-dialog.test.tsx index 5f5c8e66fc0..4ed592404a7 100644 --- a/packages/ui/src/__tests__/new-project-dialog.test.tsx +++ b/packages/ui/src/__tests__/new-project-dialog.test.tsx @@ -82,7 +82,7 @@ async function mountDialog() { installDomStubs(window); Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); - const submitted: string[] = []; + const submitted: (string | undefined)[] = []; let openChanges = 0; const container = document.querySelector('#root'); assert.ok(container); @@ -135,7 +135,7 @@ 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 submits as "no name" so the folder can still be chosen', async () => { const dialog = await mountDialog(); try { let event!: Event; @@ -144,7 +144,34 @@ test('an empty name submits nothing at all', async () => { }); assert.equal(event.defaultPrevented, true); - assert.deepEqual(dialog.submitted, []); + // The dialog asked to close and handed back no name — the caller opens + // the folder picker and the catalog uses the folder's basename. + assert.equal(dialog.openChanges(), 1); + assert.deepEqual(dialog.submitted, [undefined]); + } finally { + await dialog.dispose(); + } +}); + +test('a typed name is trimmed before it travels', async () => { + const dialog = await mountDialog(); + try { + const input = document.querySelector('input'); + assert.ok(input); + await act(() => { + input.value = ' Spaced '; + const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey); + const props = (input as unknown as Record)[propsKey] as { + onChange: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + }; + props.onChange({ target: input, defaultPrevented: false }); + }); + await act(() => { + dialog.submit(); + }); + + assert.deepEqual(dialog.submitted, ['Spaced']); } finally { await dialog.dispose(); } diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 27764eb506a..c279edb2305 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 optionally names the project. */ newProject: string; /** Header of the New project dialog. */ newProjectTitle: string; - /** One line under the header: name first, folder next. */ + /** One line under the header: the name is an optional override of the folder name. */ newProjectDescription: string; newProjectNameLabel: string; /** The dialog's submit, which opens the folder picker. */ @@ -513,7 +513,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}`, }, @@ -637,7 +637,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}`, }, @@ -758,7 +758,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: 'Name the project, or leave it blank to use the folder name.', newProjectNameLabel: 'Project name (optional)', 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..e657b61fec6 100644 --- a/packages/ui/src/new-project-dialog.tsx +++ b/packages/ui/src/new-project-dialog.tsx @@ -25,22 +25,22 @@ import { getConversationCopy } from './conversation-copy.js'; import { useUiLocale } from './locale-context.js'; /** - * Naming a project before its folder is chosen. + * Optionally naming a project before its folder is chosen. * * 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. + * the caller, which then asks for the directory. The name is an override, not + * a requirement: leaving it blank means "call this project after the folder I + * pick", and the catalog's own basename fallback does the naming. * * 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. There is nothing to + * validate on the way through — an empty field is a valid choice. */ 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; + /** The typed name, trimmed; absent when the caller should use the folder name. */ + onSubmit(name?: string): void; }) { const copy = getConversationCopy(useUiLocale()).workspace; const [name, setName] = useState(''); @@ -67,11 +67,10 @@ export function NewProjectDialog(props: { 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); + // Escape and the header's close control are the two ways out. An empty + // field submits as "no name" — the caller's basename fallback does the + // naming — so nothing is blocked on the way through. + props.onSubmit(trimmed || undefined); } return ( @@ -87,7 +86,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..b4f9065e5e0 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 + * Create a project on this host. The dialog collects an optional name (see * `NewProjectDialog`) and passes it here; the handler then asks for the - * directory and registers the project under that name. + * directory and registers the project under that name, or under the folder + * name when none was given. */ - 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 dialog optionally names the + // project, then the caller's `onAdd` opens the folder picker — a blank name + // simply means the folder's name will be used. const [newProjectGroupId, setNewProjectGroupId] = useState(null); const newProjectGroup = picker.groups.find((group) => group.id === newProjectGroupId);