Skip to content
Merged
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
22 changes: 22 additions & 0 deletions apps/desktop/src/main/__tests__/project-add-restore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectRecord>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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! },
Expand Down
15 changes: 8 additions & 7 deletions apps/desktop/src/renderer/settings/projects-settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
}}
/>
Expand Down
33 changes: 30 additions & 3 deletions packages/ui/src/__tests__/new-project-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, unknown>)[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();
}
Expand Down
10 changes: 5 additions & 5 deletions packages/ui/src/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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}`,
},
Expand Down Expand Up @@ -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}`,
},
Expand Down Expand Up @@ -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}`,
},
Expand Down
Loading