fix(projects): require creation directories and show remote directory links

Require a root folder in the local project creation dialog and IPC while preserving updates to historical projects with blank paths. Show remote symlinks whose targets are directories and resolve their canonical paths when browsing.

Validation: focused UI, contract, IPC and SSH tests passed; typecheck and lint passed. Verified directory links on the shared Linux Host. Full test run timed out after a Magic Notes storage timing failure.

Release note: 新建本地项目必须选择目录;远程项目选择目录时可查看并进入指向目录的符号链接。
This commit is contained in:
mesalogo
2026-10-04 15:00:13 +08:00
parent 629e82545b
commit ee186f9f70
15 changed files with 309 additions and 29 deletions
@@ -150,6 +150,8 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
左栏依次提供“全部项目、本地、远程、消息通道”分类,远程能力关闭时隐藏远程分类及其项目;支持跨分类搜索、远程主机折叠及独立项目设置。项目行按名称、活动数量、目录分行显示,无活动时省略数量行,项目顺序不随活动数量变化。
悬停或键盘聚焦项目只预览右栏,点击项目直接进入,优先恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目无会话时创建,消息通道无会话时保持空态。点击右栏会话进入确切会话。
右栏默认依次列待处理、运行中、最近会话,最近会话最多 10 条;已完成未查看在行内标注。“全部、待处理、运行中、已完成”筛选与“新建会话”同排,新建使用预览项目,全部项目范围使用当前项目,消息通道禁用手动新建。左栏底部“新建项目”进入真实项目创建流程。
新建本地项目必须填写名称并选择根目录,空目录或纯空白目录不能创建。此要求仅适用于创建;历史空目录项目仍可编辑和保存,内置默认项目与消息通道项目的初始化规则保持不变。
活动计数、待处理、运行中及已完成筛选不受最近 10 条限制;菜单不继承侧栏会话搜索条件。计数与排除规则见
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
@@ -1,5 +1,15 @@
# 应用导航实施进度
## 2026-10-04:新建本地项目必选目录
对应 FR-14 的项目创建入口,规则见 [PRD](./prd.md#fr-14-项目活动汇总)及[界面设计](./ui-design.md#10-项目活动汇总)。创建表单已增加中英文必填提示、无障碍关联及空目录提交检查;共享创建 schema 拒绝空白路径,更新 IPC 使用独立 schema,保留历史空目录项目的保存能力。
- `npx vitest run --project unit src/renderer/src/ProjectSwitcher.test.tsx src/shared/assistant-contracts.test.ts`:57 项通过,覆盖空白目录、目录选择与取消、正常创建、中英文提示、历史空目录设置保存及既有 SSH 界面回归。
- `npx vitest run src/main/ipc.test.ts -t "requires a directory through project creation IPC"`:1 项通过,172 项未选中。调用生产 IPC handler 和真实内存 SQLite,验证空目录创建不写库、有效目录创建落库,以及历史空目录更新成功。
- `npm run typecheck` 和本次修改的 8 个源码/测试文件的定向 ESLint 检查通过。
本次未运行完整测试集或原生 Electron 目录选择器验收,未修改远程主机文档、Agent 或远程连接流程;模型调用 0 次。
## 2026-10-04:双行入口合并为单个按钮
对应 FR-14、US-E1 至 US-E3。项目名和活动摘要现在属于同一个 `60px` 按钮,共用键盘焦点、展开状态和菜单关联。点击任一行默认预览当前项目,全部项目范围在菜单内切换;活动描述和按钮外的恢复反馈通过 `aria-describedby` 关联。移除了独立摘要触发引用与入口范围状态,保留最近 10 条及现有活动计数,没有新增轮询。
@@ -479,6 +479,7 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
- 聚合状态新增 `completed`,全局和项目计数包含 `attention`、`running`、`completed`,
同一 Conversation ID 只按最高优先级计数;活动行为空时以“暂无活动”替换计数,保留全局入口。
- `App` 向 `ProjectSwitcher` 传入 `conversationStore`、活动行、侧栏可见状态和确切会话跳转、新建回调。`WorkspaceMenu` 持有分类、预览范围、搜索及状态筛选;分类复用 `PageTabs`,状态筛选复用 `SegmentedControl`,项目行与真实设置/创建表单继续由 `ProjectSwitcher` 提供。
- 本地创建使用共享 `projectCreateSchema`,`rootPath` 经 trim 后要求 1 至 4096 个字符,`projectsCreate` IPC 在写库前校验。`projectUpdateSchema` 保留原有空目录兼容性,`projectsUpdate` 请求显式使用该 schema;持久化执行空间、默认项目和通道初始化不增加目录限制。`ProjectSwitcher` 在创建按钮和保存入口检查空目录,设置保存沿用原规则。
- `workspace-menu-selectors.ts` 订阅会话 Store,投影 ID、项目、标题、排序时间及通道标记,以字段相等比较保留未变化结果;排序时间为 `Math.max(conversationActivityTime(conversation), activityTimes.get(conversation.id) ?? 0)`。与活动行按 ID 合并后派生分组和最近 10 条;仅有活动、尚无摘要的行使用 Store 时间,缺失时取 0。悬停只改变 Renderer 预览范围,不发 IPC、不加载详情或请求模型。打开菜单所需的既有主机快照读取与悬停预览分开。
- Store 的 `activityTimes` 保存本次应用会话内的访问/状态变化时间,更新时间为 `Math.max(Date.now(), lastActivityTime + 1)`。`ProjectSwitcher` 调用 `recordActivityStatuses`:首次快照只建立基线,后续状态变化触发更新,但 `completed` 变为无状态的完成提醒清除不触发更新。`WorkspaceMenu` 经 `useSyncExternalStore` 订阅该时间映射;时间不持久化,悬停不更新,普通侧栏原有消息排序规则不变。
- 项目、会话列表均使用共享 `useListWindow`,分别估算 `64px`、`56px` 行高并测量实际高度,保留键盘目标行;范围改变时重置滚动。窗口化仅限制挂载行数,不截断活动集合,不代表已有性能基准结果。
@@ -311,6 +311,8 @@ OCR 忙碌或模型不可用时禁用加载与释放,并在执行模块再次
左栏固定搜索、分类及底部“新建项目”,项目列表独立滚动。分类使用共享 `PageTabs` 的 `segmented` 变体,依次为“全部项目、本地、远程、消息通道”,保留 `tablist`/`tab`/`tabpanel`、游标焦点及方向键语义;远程能力关闭时隐藏远程分类。搜索跨分类匹配,SSH Host 分组可折叠,连接状态独立于会话数量。
新建本地项目的根目录字段常显“根目录为必填项,请选择目录后创建项目。”,英文为“Required: select a root folder to create the project.”。只读路径框声明 `aria-required`,通过 `aria-describedby` 关联提示;沿用目录选择按钮。名称或目录去除首尾空白后为空时禁用“创建”。取消目录选择保留原选择和其他草稿;项目设置不显示创建专用提示,也不要求历史空目录项目补选目录。
左栏使用 `--surface-subtle`,与右栏之间以 `1px` 的 `--border-control` 竖线分隔。搜索框使用 `border-box`,宽度包含边框和内边距,并扣除左右外边距,避免越过分隔线。全部项目及搜索结果按实际存在的本地、远程、消息通道显示分组标题,组间用横线和留白区分;远程组内保留 Host 标题。
项目行显示名称、活动数量、目录三行,无活动时省略数量行,高度随内容增长;恢复状态继续显示在目录下方。当前项目以勾选和 `aria-checked` 标识,预览使用单独背景。设置按钮独立,桌面悬停或聚焦显示,窄窗口及粗指针设备可直接访问;打开既有真实项目设置,保留目录选择、Runtime、模式、删除及远程恢复等适用操作,不触发项目切换。悬停或聚焦项目只预览右栏,点击项目直接进入;通道无会话时保留空态。
@@ -279,7 +279,9 @@ Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、
- 创建项目时,用户可以直接输入远端工作目录,或通过输入框右侧的文件夹按钮浏览并选择目录。
- 项目保存前的目录浏览使用 Main 管理的只读、有界 SFTP,只返回目录;每次请求重新解析并校验当前 Host revision 与 Host Key generation,不安装 Agent、不暴露任意 shell/SFTP 接口。
- 普通目录直接列出;符号链接通过 `stat` 检查目标,仅列出目录目标。文件链接、断链、循环链接和目标不可读的链接不列出。返回项保留当前目录下的链接名称和路径,进入该项时沿用 `realpath` 解析目标路径。
- 浏览从 SSH 账号 Home 或当前有效绝对路径开始,限制扫描次数、返回条目和总时限;取消、Host 变化、超时或连接失败时关闭 SFTP 并保留用户原先输入。
- 每次浏览最多扫描 2,000 项、读取 64 批、返回 500 个目录,总时限 30 秒。链接检查逐项执行,计入同一次浏览的扫描及时间限制;检查期间取消、超时或通道报错仍关闭目录句柄和 SFTP 通道,忽略迟到的结果。
- 选择目录只更新项目草稿,仍需通过正常的 Agent、Workspace、Runtime 验证和项目保存事务才会持久化。
- Ask Workspace handle 不暴露写入方法。
- Execute Workspace handle 可读写,但这不是 Execute 的唯一权限面;Execute Runtime 本身使用 SSH 账号的正常权限。
@@ -1093,3 +1095,30 @@ node "C:\Users\jiang\AppData\Local\Temp\opencode\lifecycle-host-run.cjs" driver
延迟或清理失败,也没有覆盖未知终态后的新 binding 场景、Continue、Execute、断线恢复、
完整 UI 操作或签名包安装。故障时序与未知终态的下一次发送以本机定向回归为证据,不能用
这两轮成功请求替代。
## 目录符号链接验证(2026-10-04)
`bounded-directory-sftp.ts` 已支持目录链接。该路径由 Desktop Main 的 SSH connection
lease 调用,不经过 Agent daemon 或 Runtime;本次直接构建并运行修改后的 Main SFTP
源码,无需更改 Host 上的 Agent 安装。
聚焦命令 `npm test -- src/main/ssh/bounded-directory-sftp.test.ts src/main/ssh/ssh-host-directory-browser.test.ts src/main/ssh/ssh-connection-pool.test.ts`
通过 3 个文件、40 项测试,覆盖目录目标筛选、条目路径、扫描及返回上限,以及等待
`stat` 时取消、超时、通道错误、迟到回调和清理。`npm run lint` 通过。
最终 `npm run typecheck` 通过;IPC 聚焦测试
`npx vitest run src/main/ipc.test.ts -t "requires a directory through project creation IPC"`
通过 1 项,172 项未选中。
全量 `npm test` 在 120 秒后由执行器终止,结束前报告 Magic Notes storage 启动读取
耗时测试失败;本轮没有全量通过结论。
真实共享 Linux Host 验证复用 GoodBuddy safeStorage 凭据和固定 Host Key。LAN
`192.168.0.23` 未建立连接,VPN `10.7.0.23` 成功。测试在独立
`/root/tmp/gb-directory-links-*` 目录创建普通目录、相对及绝对目录链接、链式链接、
文件链接、断链和循环链接,通过生产 `lease.listDirectories` 验证可见项与原始条目
路径,再进入三类目录链接确认返回目标的规范路径及子目录。取消一次真实浏览后,
同一 lease 再次浏览成功。测试目录已删除,模型调用 0 次。
临时探测脚本为 `C:\Users\jiang\AppData\Local\Temp\opencode\directory-symlink-host.ts`,
运行命令为 `node "C:\Users\jiang\AppData\Local\Temp\opencode\directory-symlink-host-run.cjs"`,
退出码 0。Host 场景未注入目标权限错误或 `stat` 超时,这些失败分支由聚焦回归覆盖;
本次验证范围不含 Renderer 操作和项目创建验证。
+27
View File
@@ -6464,6 +6464,33 @@ describe('registerIpcHandlers agent terminal state', () => {
}
})
it('requires a directory through project creation IPC while preserving blank-path updates', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const harness = createHarness({}, undefined, 'always', undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
const event = trustedEvent(harness.webContents)
const create = electronMocks.handlers.get(ipcChannels.projectsCreate)!
const input = { name: 'Directory project', description: '', rootPath: '', defaultWorkMode: 'ask' as const }
const initialCount = database.listProjects().length
for (const rootPath of ['', ' ']) {
expect(() => create(event, { ...input, rootPath })).toThrow()
}
expect(database.listProjects()).toHaveLength(initialCount)
const created = create(event, { ...input, rootPath: ` ${process.cwd()} ` }) as AssistantProject
expect(database.getProject(created.id).rootPath).toBe(process.cwd())
database.updateProject(created.id, input)
await electronMocks.handlers.get(ipcChannels.projectsUpdate)!(event, {
projectId: created.id, input: { ...input, name: 'Renamed historical project' }
})
expect(database.getProject(created.id)).toMatchObject({ name: 'Renamed historical project', rootPath: '' })
} finally {
await harness.dispose()
database.close()
}
})
it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z'))
+2 -1
View File
@@ -147,6 +147,7 @@ import {
normalizeInteractiveWorkMode,
projectChannelLabels,
projectCreateSchema,
projectUpdateSchema,
scheduleCreateSchema,
expertCreateSchema,
type AssistantSchedule,
@@ -729,7 +730,7 @@ const approvalResponseSchema = z
const projectUpdateRequestSchema = z
.object({
projectId: assistantIdSchema,
input: projectCreateSchema
input: projectUpdateSchema
})
.strict()
const projectArchiveRequestSchema = z
+111
View File
@@ -76,6 +76,9 @@ function createSftp(options: {
) => void
) => callback(undefined, batches.shift() ?? [])
),
stat: vi.fn((_path, callback) => {
callback(new Error('Target unavailable'))
}),
close: vi.fn(
(
_handle: Buffer,
@@ -171,6 +174,114 @@ describe('bounded directory SFTP', () => {
)
})
it('includes only directory link targets and preserves their entry paths', async () => {
const sftp = createSftp({
canonicalPath: '/',
batches: [[
entry('relative', 'symbolic-link'),
entry('absolute', 'symbolic-link'),
entry('file-link', 'symbolic-link'),
entry('broken', 'symbolic-link'),
entry('denied', 'symbolic-link'),
entry('loop', 'symbolic-link'),
entry('../invalid', 'symbolic-link'),
entry('regular')
]]
})
sftp.stat = vi.fn((path, callback) => {
if (path === '/relative' || path === '/absolute') {
callback(undefined, entry('target').attrs)
} else if (path === '/file-link') {
callback(undefined, entry('target', 'file').attrs)
} else {
callback(Object.assign(new Error('Cannot stat target'), {
code: path === '/denied' ? 3 : 2
}))
}
})
const result = await listBoundedSftpDirectories(opener(sftp), '/')
expect(result.entries).toEqual([
{ name: 'absolute', path: '/absolute' },
{ name: 'regular', path: '/regular' },
{ name: 'relative', path: '/relative' }
])
expect(sftp.stat).toHaveBeenCalledTimes(6)
expect(sftp.realpath).toHaveBeenCalledTimes(2)
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('bounds link lookups by the scanned-entry limit and keeps the return limit', async () => {
const sftp = createSftp({ batches: [
Array.from({ length: 2_100 }, (_, index) =>
entry(`link-${index.toString().padStart(4, '0')}`, 'symbolic-link'))
] })
sftp.stat = vi.fn((_path, callback) => {
callback(undefined, entry('target').attrs)
})
const result = await listBoundedSftpDirectories(opener(sftp))
expect(sftp.stat).toHaveBeenCalledTimes(2_000)
expect(sftp.readdir).toHaveBeenCalledOnce()
expect(result.entries).toHaveLength(500)
expect(result.entries[499]?.name).toBe('link-0499')
expect(result.truncated).toBe(true)
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it.each(['abort', 'timeout', 'channel-error'] as const)(
'cleans up a pending STAT on %s and ignores late results', async (reason) => {
vi.useFakeTimers()
try {
const sftp = createSftp({ batches: [[
entry('first', 'symbolic-link'),
entry('second', 'symbolic-link')
]] })
let callback: Parameters<DirectorySftp['stat']>[1] | undefined
sftp.stat = vi.fn((_path, next) => { callback = next })
const controller = new AbortController()
const pending = listBoundedSftpDirectories(opener(sftp), undefined, controller.signal)
const expected = expect(pending).rejects.toThrow(
reason === 'timeout' ? '目录浏览超时' : reason
)
expect(sftp.stat).toHaveBeenCalledOnce()
expect(sftp.readdir).toHaveBeenCalledOnce()
if (reason === 'timeout') {
await vi.advanceTimersByTimeAsync(30_000)
} else if (reason === 'abort') {
controller.abort(new Error(reason))
} else {
(sftp as unknown as EventEmitter).emit('error', new Error(reason))
}
await expected
callback?.(undefined, entry('target').attrs)
await Promise.resolve()
await Promise.resolve()
expect(sftp.stat).toHaveBeenCalledOnce()
expect(sftp.readdir).toHaveBeenCalledOnce()
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
expect((sftp as unknown as EventEmitter).listenerCount('error')).toBe(0)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
}
)
it('closes resources if issuing STAT throws', async () => {
const sftp = createSftp({ batches: [[entry('link', 'symbolic-link')]] })
sftp.stat = vi.fn(() => { throw new Error('STAT failed') })
await expect(listBoundedSftpDirectories(opener(sftp))).rejects.toThrow('STAT failed')
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('sorts names by their UTF-8 bytes rather than UTF-16 code units', async () => {
const sftp = createSftp({
batches: [[entry('\u{10000}'), entry('\ue000')], []]
+50 -22
View File
@@ -18,6 +18,7 @@ export type DirectorySftp = Pick<
| 'realpath'
| 'opendir'
| 'readdir'
| 'stat'
| 'close'
| 'end'
| 'on'
@@ -67,7 +68,7 @@ function parentPath(path: string): string | null {
return separator === 0 ? '/' : path.slice(0, separator)
}
function validDirectoryName(entry: FileEntryWithStats): boolean {
function validDirectoryEntry(entry: FileEntryWithStats): boolean {
const name = entry?.filename
if (
typeof name !== 'string' ||
@@ -84,9 +85,9 @@ function validDirectoryName(entry: FileEntryWithStats): boolean {
try {
return (
typeof entry.attrs?.isDirectory === 'function' &&
entry.attrs.isDirectory() === true &&
typeof entry.attrs.isSymbolicLink === 'function' &&
entry.attrs.isSymbolicLink() === false
(entry.attrs.isSymbolicLink() === true ||
entry.attrs.isDirectory() === true)
)
} catch {
return false
@@ -221,6 +222,38 @@ export function listBoundedSftpDirectories(
finishError(new Error('SSH 目录浏览超时'))
}, OPERATION_TIMEOUT_MS)
const inspectEntries = async (
entries: FileEntryWithStats[]
): Promise<void> => {
for (const entry of entries) {
if (settled) {
return
}
if (!validDirectoryEntry(entry)) {
continue
}
const path = joinPath(listingPath!, entry.filename)
if (!isCanonicalAbsolutePosixPath(path)) {
continue
}
if (entry.attrs.isSymbolicLink()) {
// STAT follows links; keep the entry path for later realpath navigation.
const isDirectory = await new Promise<boolean>((resolve) => {
sftp!.stat(path, (error, attrs) => {
resolve(!settled && !error && attrs?.isDirectory() === true)
})
})
if (settled) {
return
}
if (!isDirectory) {
continue
}
}
directories.add(entry.filename)
}
}
const readNextBatch = (): void => {
if (settled || !sftp || !handle) {
return
@@ -267,26 +300,21 @@ export function listBoundedSftpDirectories(
MAXIMUM_SCANNED_ENTRIES - scannedEntries
const inspected = list.slice(0, remaining)
scannedEntries += inspected.length
for (const entry of inspected) {
if (
validDirectoryName(entry) &&
isCanonicalAbsolutePosixPath(
joinPath(listingPath!, entry.filename)
)
) {
directories.add(entry.filename)
void inspectEntries(inspected).then(() => {
if (settled) {
return
}
}
if (
list.length > inspected.length ||
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
) {
truncated = true
finishSuccess()
} else {
readNextBatch()
}
if (
list.length > inspected.length ||
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
) {
truncated = true
finishSuccess()
} else {
readNextBatch()
}
}).catch(finishError)
})
} catch (error) {
finishError(error)
+41 -2
View File
@@ -444,7 +444,7 @@ describe('ProjectSwitcher runtime fields', () => {
})
it('creates an ordinary project with DeepSeek Harness', async () => {
const { onCreate } = renderSwitcher()
const { onCreate, onSelectRoot } = renderSwitcher()
openCreate('新建项目')
const dialog = screen.getByRole('dialog', { name: '新建项目' })
@@ -455,6 +455,9 @@ describe('ProjectSwitcher runtime fields', () => {
// New projects start from a concrete execution mode.
expect(runtime).not.toHaveValue('')
fireEvent.change(runtime, { target: { value: 'deepseek-harness' } })
onSelectRoot.mockResolvedValue('C:\\Workspace')
fireEvent.click(within(dialog).getByRole('button', { name: '选择项目根目录' }))
await waitFor(() => expect(within(dialog).getByRole('button', { name: '创建' })).toBeEnabled())
fireEvent.click(
within(dialog).getByRole('button', { name: '创建' })
)
@@ -469,8 +472,41 @@ describe('ProjectSwitcher runtime fields', () => {
)
})
it('requires a local directory and preserves the selection when the picker is cancelled', async () => {
const { onCreate, onSelectRoot } = renderSwitcher()
openCreate('新建项目')
const dialog = screen.getByRole('dialog', { name: '新建项目' })
const create = within(dialog).getByRole('button', { name: '创建' })
const select = within(dialog).getByRole('button', { name: '选择项目根目录' })
const root = within(dialog).getByLabelText('根目录')
fireEvent.change(within(dialog).getByLabelText('名称'), { target: { value: 'Directory project' } })
expect(root).toHaveAttribute('aria-required', 'true')
expect(root).toHaveAccessibleDescription('根目录为必填项,请选择目录后创建项目。')
expect(create).toBeDisabled()
fireEvent.click(create)
expect(onCreate).not.toHaveBeenCalled()
for (const selection of [undefined, ' ', 'C:\\Selected', undefined]) {
onSelectRoot.mockResolvedValueOnce(selection)
await act(async () => { fireEvent.click(select) })
if (selection === 'C:\\Selected' || root.getAttribute('value') === 'C:\\Selected') {
expect(root).toHaveValue('C:\\Selected')
expect(create).toBeEnabled()
} else {
expect(create).toBeDisabled()
}
}
fireEvent.click(create)
await waitFor(() => {
expect(onCreate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ rootPath: 'C:\\Selected' }))
expect(screen.queryByRole('dialog', { name: '新建项目' })).not.toBeInTheDocument()
})
})
it('edits an ordinary project to use DeepSeek Harness and switches work mode by keyboard', async () => {
const { onUpdate } = renderSwitcher()
const { onUpdate } = renderSwitcher({
...project, rootPath: '', executionSpace: { kind: 'local', rootPath: '' }
})
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
fireEvent.click(screen.getByRole('menuitem', {
@@ -501,6 +537,7 @@ describe('ProjectSwitcher runtime fields', () => {
expect(onUpdate).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
rootPath: '',
defaultWorkMode: 'execute',
runtimeSelection: { provider: 'deepseek-harness' }
})
@@ -516,6 +553,8 @@ describe('ProjectSwitcher runtime fields', () => {
const dialog = screen.getByRole('dialog', { name: 'New project' })
expect(within(dialog).getByRole('group', { name: 'Default Runtime for new conversations' })).toBeInTheDocument()
expect(within(dialog).getByLabelText('Execution mode')).not.toHaveValue('')
expect(within(dialog).getByLabelText('Root folder')).toHaveAttribute('aria-required', 'true')
expect(within(dialog).getByLabelText('Root folder')).toHaveAccessibleDescription('Required: select a root folder to create the project.')
expect(
within(dialog)
.getAllByRole('group', { name: 'Default mode' })
+14 -2
View File
@@ -934,6 +934,7 @@ function ProjectSwitcherView({
}
const save = async (): Promise<void> => {
if (dialogMode === 'create' && !draft.rootPath.trim()) return
setSaving(true)
setError(undefined)
try {
@@ -1346,7 +1347,12 @@ function ProjectSwitcherView({
{t('projectSwitcher.dialog.fields.rootPath')}
</span>
<div className="project-create-card__path">
<input readOnly value={draft.rootPath} />
<input
aria-required={dialogMode === 'create' || undefined}
aria-describedby={dialogMode === 'create' ? 'project-root-required' : undefined}
readOnly
value={draft.rootPath}
/>
<button
aria-label={t(
'projectSwitcher.dialog.selectRoot'
@@ -1360,6 +1366,11 @@ function ProjectSwitcherView({
</button>
</div>
</label>
{dialogMode === 'create' && (
<small id="project-root-required">
{t('projectSwitcher.dialog.rootRequired')}
</small>
)}
<ProjectWorkModeFields
ariaLabel={t(
'projectSwitcher.dialog.fields.defaultMode'
@@ -1904,7 +1915,8 @@ function ProjectSwitcherView({
<button
className="primary-button"
disabled={
busy || !draft.name.trim() || confirmingDelete
busy || !draft.name.trim() || confirmingDelete ||
(dialogMode === 'create' && !draft.rootPath.trim())
}
onClick={() => void save()}
type="button"
@@ -105,6 +105,7 @@ export const workspace = {
'Applies only to new conversations in this project. Existing conversations are unchanged.',
channelManaged: 'GoodBuddy manages channel project names.',
selectRoot: 'Select project root folder',
rootRequired: 'Required: select a root folder to create the project.',
danger: {
title: 'Danger zone',
description:
@@ -102,6 +102,7 @@ export const workspace = {
'仅应用于此项目中新建的对话,不会更改已有对话。',
channelManaged: '通道项目名称由 GoodBuddy 管理。',
selectRoot: '选择项目根目录',
rootRequired: '根目录为必填项,请选择目录后创建项目。',
danger: {
title: '危险操作',
description:
+14
View File
@@ -159,6 +159,20 @@ describe('isUntouchedBuiltInDefaultProject', () => {
})
describe('project execution space contracts', () => {
it.each(['', ' ', '\t\n'])('rejects blank creation paths but preserves updates: %j', (rootPath) => {
const input = { name: 'Project', description: '', rootPath, defaultWorkMode: 'ask' }
const result = projectCreateSchema.safeParse(input)
expect(result.success).toBe(false)
if (!result.success) expect(result.error.issues[0]?.path).toEqual(['rootPath'])
expect(projectUpdateSchema.parse(input).rootPath).toBe('')
})
it.each(['C:\\Workspace', '/home/user/project'])('accepts and trims a creation directory: %s', (rootPath) => {
expect(projectCreateSchema.parse({
name: 'Project', description: '', rootPath: ` ${rootPath} `, defaultWorkMode: 'ask'
}).rootPath).toBe(rootPath)
})
it('parses strict local and SSH output without normalizing paths', () => {
expect(
projectExecutionSpaceSchema.parse({
+4 -2
View File
@@ -277,7 +277,7 @@ export function normalizeInteractiveWorkMode(
return workMode === 'execute' ? 'execute' : 'ask'
}
export const projectCreateSchema = z
export const projectUpdateSchema = z
.object({
name: z.string().trim().min(1).max(120),
description: z.string().trim().max(2_000),
@@ -288,7 +288,9 @@ export const projectCreateSchema = z
})
.strict()
export const projectUpdateSchema = projectCreateSchema
export const projectCreateSchema = projectUpdateSchema.extend({
rootPath: z.string().trim().min(1).max(4_096)
})
export type ProjectCreateInput = z.infer<typeof projectCreateSchema>