重构AGC项目管理界面
Project CI / Repository checks (push) Successful in 1m40s
Project CI / Frontend tests (push) Successful in 3m27s
Project CI / Backend tests (push) Successful in 4m17s
Project CI / Native shell tests (push) Successful in 14m20s

参考成熟桌面项目管理器重构紧凑项目表、搜索和行尾菜单

支持Windows路径及Godot根目录和一层子目录工程识别与导入

修复1280×720页面溢出、列表滚动和末行菜单裁切

补充项目页回归测试、PRD、技术方案和项目记忆
This commit is contained in:
2026-08-15 11:43:58 +08:00
parent 578f8019fc
commit 9f5c84ee71
9 changed files with 821 additions and 246 deletions
@@ -1,9 +1,153 @@
import { FolderKanban, FolderOpen, FolderPlus } from 'lucide-react';
import {
CircleAlert,
Ellipsis,
FolderKanban,
FolderOpen,
FolderPlus,
Gamepad2,
Search,
X,
} from 'lucide-react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { closeDialogOnEscape } from '../../app/dialogs';
import type { RecentProjectRow } from './model';
import type { HomeProjectCreationController } from './useHomeProjectCreation';
import type { RecentProjectsController } from './useRecentProjects';
function projectKindLabel(project: RecentProjectRow) {
if (project.projectKind === 'godot') {
return project.godotProjectRoot && project.godotProjectRoot !== '.'
? `Godot · ${project.godotProjectRoot}`
: 'Godot';
}
if (project.projectKind === 'web') {
return 'GameAgent';
}
return '待识别';
}
function projectStatusTone(project: RecentProjectRow) {
if (project.canOpen) {
return 'ready';
}
if (project.status === '检查中') {
return 'checking';
}
return 'warning';
}
function ProjectMoreMenu({
project,
recentProjects,
}: {
project: RecentProjectRow;
recentProjects: RecentProjectsController;
}) {
const [open, setOpen] = useState(false);
const [dropUp, setDropUp] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useLayoutEffect(() => {
if (!open) {
setDropUp(false);
return;
}
const trigger = triggerRef.current;
const menu = menuRef.current?.querySelector<HTMLElement>(
'.launcher-project-more-menu',
);
const table = trigger?.closest<HTMLElement>('.launcher-project-table');
if (!trigger || !menu || !table) {
return;
}
const triggerBounds = trigger.getBoundingClientRect();
const tableBounds = table.getBoundingClientRect();
const menuHeight = menu.offsetHeight;
const availableBelow =
Math.min(window.innerHeight, tableBounds.bottom) - triggerBounds.bottom;
const availableAbove = triggerBounds.top - Math.max(0, tableBounds.top);
setDropUp(
availableBelow < menuHeight + 8 && availableAbove > availableBelow,
);
}, [open]);
useEffect(() => {
if (!open) {
return;
}
function closeIfOutside(event: PointerEvent) {
if (
event.target instanceof Node &&
!menuRef.current?.contains(event.target)
) {
setOpen(false);
}
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key !== 'Escape') {
return;
}
setOpen(false);
triggerRef.current?.focus();
}
document.addEventListener('pointerdown', closeIfOutside);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('pointerdown', closeIfOutside);
document.removeEventListener('keydown', closeOnEscape);
};
}, [open]);
return (
<div className="launcher-project-more" ref={menuRef}>
<button
ref={triggerRef}
type="button"
className="launcher-project-more-trigger"
aria-label={`${project.name}的更多操作`}
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((current) => !current)}
>
<Ellipsis size={18} aria-hidden="true" />
</button>
{open ? (
<div
className={`launcher-project-more-menu${dropUp ? ' launcher-project-more-menu-drop-up' : ''}`}
role="menu"
>
<button
type="button"
role="menuitem"
disabled={!project.canReveal}
onClick={() => {
setOpen(false);
void recentProjects.handleRevealProjectDirectory(project.path);
}}
>
<FolderOpen size={15} aria-hidden="true" />
</button>
<button
type="button"
role="menuitem"
className="launcher-project-remove-action"
onClick={() => {
setOpen(false);
recentProjects.handleRecentWorkspaceRemove(project.path);
}}
>
<X size={15} aria-hidden="true" />
</button>
</div>
) : null}
</div>
);
}
export function ProjectsPage({
status,
homeProject,
@@ -13,109 +157,137 @@ export function ProjectsPage({
homeProject: HomeProjectCreationController;
recentProjects: RecentProjectsController;
}) {
const rows = recentProjects.filteredProjectRows;
const hasStoredProjects = recentProjects.projectRows.length > 0;
return (
<section className="launcher-page launcher-projects-page">
<header>
<div>
<h1></h1>
<header className="launcher-projects-toolbar">
<div className="launcher-projects-heading">
<h1></h1>
<p className="launcher-project-page-status" aria-live="polite">
{recentProjects.recentWorkspaceRefreshing
? '正在检查项目状态'
: status}
</p>
</div>
</header>
<div className="launcher-project-actions" aria-label="项目操作">
<button
type="button"
className="launcher-project-open-action"
aria-label="打开项目"
disabled={homeProject.projectBusy}
onClick={() => void homeProject.pickAndOpenProject()}
>
<FolderOpen size={18} aria-hidden="true" />
<span>
<strong>
<div className="launcher-projects-controls">
<label className="launcher-project-search">
<Search size={16} aria-hidden="true" />
<input
type="search"
aria-label="搜索项目"
placeholder="搜索名称、路径、类型或状态"
value={recentProjects.projectSearchQuery}
onChange={(event) =>
recentProjects.setProjectSearchQuery(event.target.value)
}
/>
{recentProjects.projectSearchQuery ? (
<button
type="button"
aria-label="清除项目搜索"
onClick={() => recentProjects.setProjectSearchQuery('')}
>
<X size={14} aria-hidden="true" />
</button>
) : null}
</label>
<div className="launcher-project-actions" aria-label="项目操作">
<button
type="button"
className="launcher-project-open-action"
aria-label="打开项目"
disabled={homeProject.projectBusy}
onClick={() => void homeProject.pickAndOpenProject()}
>
<FolderOpen size={16} aria-hidden="true" />
{homeProject.projectAction === 'opening' ? '打开中…' : '打开项目'}
</strong>
<small> Godot </small>
</span>
</button>
<button
type="button"
className="launcher-project-create-action"
aria-label="新建项目"
disabled={homeProject.projectBusy}
onClick={() => void homeProject.pickAndCreateProject()}
>
<FolderPlus size={18} aria-hidden="true" />
<span>
<strong>
</button>
<button
type="button"
className="launcher-project-create-action"
aria-label="新建项目"
disabled={homeProject.projectBusy}
onClick={() => void homeProject.pickAndCreateProject()}
>
<FolderPlus size={16} aria-hidden="true" />
{homeProject.projectAction === 'creating'
? '新建中…'
: '新建项目'}
</strong>
<small></small>
</span>
</button>
</div>
<div className="launcher-project-table">
{recentProjects.projectRows.length > 0 ? (
recentProjects.projectRows.map((project) => (
<article key={project.path}>
<button
type="button"
className="launcher-project-row-main"
disabled={!project.canOpen}
onClick={() => {
homeProject.setProjectPath(project.path);
void homeProject.openProject(project.path, 'open');
}}
>
<strong>{project.name}</strong>
<small>{project.path}</small>
</button>
<span>{project.status}</span>
<button
type="button"
disabled={!project.canOpen}
onClick={() => {
homeProject.setProjectPath(project.path);
void homeProject.openProject(project.path, 'open');
}}
>
</button>
<button
type="button"
aria-label={`显示 ${project.path}`}
disabled={!project.canReveal}
onClick={() => {
void recentProjects.handleRevealProjectDirectory(
project.path,
);
}}
>
</button>
<button
type="button"
aria-label={`移除 ${project.path}`}
onClick={() =>
recentProjects.handleRecentWorkspaceRemove(project.path)
}
>
</button>
</article>
))
) : (
<div className="launcher-empty-projects">
<FolderKanban size={28} aria-hidden="true" />
<strong></strong>
<small></small>
</button>
</div>
)}
</div>
</header>
<div className="launcher-project-list-shell" aria-label="项目列表">
<div className="launcher-project-table-header">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div className="launcher-project-table">
{rows.length > 0 ? (
rows.map((project) => (
<article key={project.path}>
<button
type="button"
className="launcher-project-row-main"
aria-label={`打开项目 ${project.name}`}
disabled={!project.canOpen}
onClick={() => {
homeProject.setProjectPath(project.path);
void homeProject.openProject(project.path, 'open');
}}
>
<span className="launcher-project-kind-icon">
{project.projectKind === 'godot' ? (
<Gamepad2 size={18} aria-hidden="true" />
) : (
<FolderKanban size={18} aria-hidden="true" />
)}
</span>
<span className="launcher-project-name-cell">
<strong>{project.name}</strong>
<small title={project.path}>{project.path}</small>
</span>
</button>
<span className="launcher-project-kind-cell">
{projectKindLabel(project)}
</span>
<span
className={`launcher-project-status launcher-project-status-${projectStatusTone(project)}`}
>
{projectStatusTone(project) === 'warning' ? (
<CircleAlert size={14} aria-hidden="true" />
) : null}
{project.status}
</span>
<ProjectMoreMenu
project={project}
recentProjects={recentProjects}
/>
</article>
))
) : hasStoredProjects ? (
<div className="launcher-empty-projects">
<Search size={26} aria-hidden="true" />
<strong></strong>
<small></small>
<button
type="button"
onClick={() => recentProjects.setProjectSearchQuery('')}
>
</button>
</div>
) : (
<div className="launcher-empty-projects">
<FolderKanban size={28} aria-hidden="true" />
<strong></strong>
<small>使</small>
</div>
)}
</div>
</div>
</section>
);
@@ -56,6 +56,10 @@ export type RecentProjectRow = {
path: string;
name: string;
status: string;
projectKind: 'web' | 'godot' | 'unknown';
godotProjectRoot: string | null;
recentRunStatus: string | null;
recentRunStopReason: string | null;
canReveal: boolean;
canOpen: boolean;
};
@@ -275,15 +279,19 @@ export function buildRecentProjectRows(
? '不是文件夹'
: directoryStatus?.manifestError
? '无法读取'
: directoryStatus?.isGameCreatorProject === false
? '未初始化'
: directoryStatus?.recentRunStatus
? `run: ${directoryStatus.recentRunStatus}${
directoryStatus.recentRunStopReason
? ` · ${directoryStatus.recentRunStopReason}`
: ''
}`
: '本地项目';
: directoryStatus?.isGodotProject === true &&
directoryStatus?.isGameCreatorProject === false
? '可导入'
: directoryStatus?.isGameCreatorProject === false
? '未初始化'
: directoryStatus?.recentRunStatus
? formatRecentProjectRunStatus(
directoryStatus.recentRunStatus,
directoryStatus.recentRunStopReason,
)
: directoryStatus?.isGodotProject
? '可打开'
: '本地项目';
const canReveal =
!recentWorkspaceRefreshing &&
Boolean(directoryStatus) &&
@@ -293,6 +301,14 @@ export function buildRecentProjectRows(
path: workspace,
name: projectName,
status,
projectKind: directoryStatus?.isGodotProject
? 'godot'
: directoryStatus?.isGameCreatorProject
? 'web'
: 'unknown',
godotProjectRoot: directoryStatus?.godotProjectRoot ?? null,
recentRunStatus: directoryStatus?.recentRunStatus ?? null,
recentRunStopReason: directoryStatus?.recentRunStopReason ?? null,
canReveal,
canOpen:
!recentWorkspaceRefreshing &&
@@ -300,7 +316,34 @@ export function buildRecentProjectRows(
directoryStatus?.exists !== false &&
directoryStatus?.isDirectory !== false &&
!directoryStatus?.manifestError &&
directoryStatus?.isGameCreatorProject !== false,
(directoryStatus?.isGameCreatorProject !== false ||
directoryStatus?.isGodotProject === true),
};
});
}
function formatRecentProjectRunStatus(
status: string,
stopReason: string | null,
) {
const statusLabel =
{
completed: '已完成',
done: '已完成',
failed: '运行失败',
running: '运行中',
pending: '等待运行',
cancelled: '已取消',
}[status] ?? status;
const stopReasonLabel = stopReason
? ({
'preview-running': '预览运行中',
completed: '已完成',
failed: '运行失败',
cancelled: '已取消',
}[stopReason] ?? stopReason)
: null;
return stopReasonLabel && stopReasonLabel !== statusLabel
? `${statusLabel} · ${stopReasonLabel}`
: statusLabel;
}
@@ -1,4 +1,10 @@
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import {
type Dispatch,
type SetStateAction,
useEffect,
useMemo,
useState,
} from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalProjectDirectoryStatus } from '../../app/types';
@@ -22,6 +28,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0);
const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] =
useState(false);
const [projectSearchQuery, setProjectSearchQuery] = useState('');
useEffect(() => {
const invoke = resolveTauriInvoke();
@@ -99,11 +106,33 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
recentWorkspaceStatuses,
recentWorkspaceRefreshing,
);
const filteredProjectRows = useMemo(() => {
const normalizedQuery = projectSearchQuery.trim().toLocaleLowerCase();
if (!normalizedQuery) {
return projectRows;
}
return projectRows.filter((project) =>
[
project.name,
project.path,
project.status,
project.godotProjectRoot ?? '',
project.projectKind === 'godot'
? 'godot'
: project.projectKind === 'web'
? 'gameagent web'
: 'unknown',
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)),
);
}, [projectRows, projectSearchQuery]);
return {
recentWorkspaces,
recentWorkspaceRefreshing,
projectRows,
filteredProjectRows,
projectSearchQuery,
setProjectSearchQuery,
recentProjectRows: projectRows
.filter((project) => project.canOpen)
.slice(0, 3),
File diff suppressed because it is too large Load Diff
@@ -1497,6 +1497,105 @@ export function registerHomeProjectCreationTests() {
}
export function registerRecentProjectsTests() {
function openProjectMoreMenu(projectName: string) {
fireEvent.click(
screen.getByRole('button', { name: `${projectName}的更多操作` }),
);
return screen.getByRole('menu');
}
it('renders and filters a compact project table with GameAgent and Godot metadata', async () => {
const paths = [
'C:\\Projects\\Skyrail',
'C:\\Projects\\RootGodot',
'C:\\Projects\\StudioWorkspace',
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
const projectPath = String(args?.projectPath ?? '');
if (projectPath === paths[0]) {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: false,
godotProjectRoot: null,
projectName: '星轨工坊',
recentRunStatus: 'done',
recentRunStopReason: 'preview-running',
};
}
if (projectPath === paths[1]) {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: false,
isGodotProject: true,
godotProjectRoot: '.',
projectName: null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: true,
godotProjectRoot: 'engine',
projectName: '云岛工作区',
recentRunStatus: 'failed',
recentRunStopReason: null,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify(paths),
);
renderLauncherProjectsAt('/?launcher');
expect(await screen.findByText('星轨工坊')).not.toBeNull();
expect(screen.getByRole('heading', { name: '项目' })).not.toBeNull();
expect(screen.getByLabelText('项目列表')).not.toBeNull();
expect(screen.getByText('GameAgent')).not.toBeNull();
expect(screen.getByText('Godot')).not.toBeNull();
expect(screen.getByText('Godot · engine')).not.toBeNull();
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
expect(screen.getByText('运行失败')).not.toBeNull();
expect(
(
screen.getByRole('button', {
name: '打开项目 RootGodot',
}) as HTMLButtonElement
).disabled,
).toBe(false);
const search = screen.getByRole('searchbox', { name: '搜索项目' });
fireEvent.change(search, { target: { value: 'godot' } });
expect(screen.queryByText('星轨工坊')).toBeNull();
expect(screen.getByText('RootGodot')).not.toBeNull();
expect(screen.getByText('云岛工作区')).not.toBeNull();
fireEvent.change(search, { target: { value: 'engine' } });
expect(screen.queryByText('RootGodot')).toBeNull();
expect(screen.getByText('云岛工作区')).not.toBeNull();
fireEvent.change(search, { target: { value: '不存在的项目' } });
expect(screen.getByText('没有匹配的项目')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }));
expect(screen.getByText('星轨工坊')).not.toBeNull();
expect(screen.getByText('RootGodot')).not.toBeNull();
});
it('removes recent launcher projects without opening them', () => {
const invoke = vi.fn(async () => undefined);
window.__TAURI__ = { core: { invoke } };
@@ -1516,10 +1615,7 @@ export function registerRecentProjectsTests() {
expect(screen.getByLabelText('最近项目')).not.toBeNull();
expect(screen.getByText('暂无最近项目')).not.toBeNull();
expect(
screen.queryByRole('button', { name: '移除 /tmp/recent-one' }),
).toBeNull();
expect(
screen.queryByRole('button', { name: '显示 /tmp/recent-one' }),
screen.queryByRole('button', { name: 'recent-one的更多操作' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '刷新' })).toBeNull();
expect(screen.queryByRole('button', { name: '清空' })).toBeNull();
@@ -1533,7 +1629,9 @@ export function registerRecentProjectsTests() {
});
fireEvent.click(
screen.getByRole('button', { name: '移除 /tmp/recent-one' }),
within(openProjectMoreMenu('recent-one')).getByRole('menuitem', {
name: '从列表移除',
}),
);
expect(screen.queryByText('/tmp/recent-one')).toBeNull();
@@ -1544,7 +1642,9 @@ export function registerRecentProjectsTests() {
);
fireEvent.click(
screen.getByRole('button', { name: '移除 /tmp/recent-two' }),
within(openProjectMoreMenu('recent-two')).getByRole('menuitem', {
name: '从列表移除',
}),
);
expect(screen.getByText('暂无项目')).not.toBeNull();
expect(window.localStorage.length).toBe(0);
@@ -1582,11 +1682,13 @@ export function registerRecentProjectsTests() {
expect(await screen.findByText('最近的项目')).not.toBeNull();
expect(
screen.queryByRole('button', { name: '显示 /tmp/recent-one' }),
screen.queryByRole('button', { name: '最近的项目的更多操作' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
fireEvent.click(
screen.getByRole('button', { name: '显示 /tmp/recent-one' }),
within(openProjectMoreMenu('最近的项目')).getByRole('menuitem', {
name: '显示目录',
}),
);
await waitFor(() => {
@@ -1683,7 +1785,7 @@ export function registerRecentProjectsTests() {
expect(screen.getByText('无法读取')).not.toBeNull();
expect(screen.getByText('检查失败')).not.toBeNull();
expect(screen.getByText('厨房突围')).not.toBeNull();
expect(screen.getByText('run: done · preview-running')).not.toBeNull();
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
expect(
(screen.getByText('missing-game').closest('button') as HTMLButtonElement)
.disabled,
@@ -1701,21 +1803,26 @@ export function registerRecentProjectsTests() {
const brokenManifestOpenButton = screen
.getByText('broken-manifest')
.closest('button') as HTMLButtonElement;
const missingRevealButton = screen.getByRole('button', {
name: '显示 /tmp/missing-game',
}) as HTMLButtonElement;
const notAFolderRevealButton = screen.getByRole('button', {
name: '显示 /tmp/not-a-folder',
}) as HTMLButtonElement;
const plainFolderRevealButton = screen.getByRole('button', {
name: '显示 /tmp/plain-folder',
}) as HTMLButtonElement;
const brokenManifestRevealButton = screen.getByRole('button', {
name: '显示 /tmp/broken-manifest',
}) as HTMLButtonElement;
const brokenStatusRevealButton = screen.getByRole('button', {
name: '显示 /tmp/broken-status',
}) as HTMLButtonElement;
const missingRevealButton = within(
openProjectMoreMenu('missing-game'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
const notAFolderRevealButton = within(
openProjectMoreMenu('not-a-folder'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
const plainFolderRevealButton = within(
openProjectMoreMenu('plain-folder'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
const brokenManifestRevealButton = within(
openProjectMoreMenu('broken-manifest'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
const brokenStatusRevealButton = within(
openProjectMoreMenu('broken-status'),
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
fireEvent.keyDown(document, { key: 'Escape' });
expect(missingOpenButton.disabled).toBe(true);
expect(plainFolderOpenButton.disabled).toBe(true);
expect(brokenManifestOpenButton.disabled).toBe(true);
@@ -1746,7 +1853,7 @@ export function registerRecentProjectsTests() {
{ projectPath: '/tmp/broken-status' },
);
fireEvent.click(screen.getByText('厨房突围').closest('button')!);
fireEvent.click(screen.getByRole('button', { name: '打开项目 厨房突围' }));
await waitFor(() => {
expect(screen.getByLabelText('项目总控对话')).not.toBeNull();
});
@@ -1792,7 +1899,7 @@ export function registerRecentProjectsTests() {
expect(screen.queryByRole('button', { name: '刷新' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
expect(await screen.findByText('自动检查后的项目')).not.toBeNull();
expect(screen.getByText('run: done · preview-running')).not.toBeNull();
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
expect(
(
screen
@@ -1835,13 +1942,15 @@ export function registerRecentProjectsTests() {
expect(screen.getByLabelText('最近项目')).not.toBeNull();
expect(screen.getByText('暂无最近项目')).not.toBeNull();
expect(
screen.queryByRole('button', { name: '移除 /tmp/slow-refresh-game' }),
screen.queryByRole('button', { name: 'slow-refresh-game的更多操作' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
expect(await screen.findByText('检查中')).not.toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '移除 /tmp/slow-refresh-game' }),
within(openProjectMoreMenu('slow-refresh-game')).getByRole('menuitem', {
name: '从列表移除',
}),
);
expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull();
@@ -1859,7 +1968,7 @@ export function registerRecentProjectsTests() {
expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull();
expect(screen.queryByText('慢速刷新旧项目')).toBeNull();
expect(screen.queryByText('run: done · late')).toBeNull();
expect(screen.queryByText('已完成 · late')).toBeNull();
expect(screen.getByText('暂无项目')).not.toBeNull();
});
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-08-11`资源卡预览、分区布局与依赖聚类,图片素材无限画布及全类型非破坏性资源编辑收口;现有 Godot 项目入口与项目根绑定
更新时间:`2026-08-15`项目管理页紧凑表格与桌面可用性收口;资源卡预览、分区布局、非破坏性资源编辑 Godot 双根合同保持不变
## 1. 产品定位
@@ -119,6 +119,14 @@
- 首次打开只在工作区根创建 `.agent/` 元数据;已有 `.agent/manifest.json` 时读取并按当前唯一文件布局校准 `godotProjectRoot`。Godot 源码、场景、资源和项目配置继续使用原目录结构,不复制工程,也不建立 `game/``assets/``memory/``exports/` 平行目录。
- Godot 项目使用 `standard` Run Profile,避免套用 Web 原型的 `game/index.html`、本地 HTTP 预览和自主 Web 游戏完成门;本期不提供 Godot 内嵌运行预览。
### 3.9 项目管理页
- 项目页采用“标题与状态 + 本地搜索 + 打开项目 / 新建项目 + 紧凑项目表格”的桌面信息架构。可以参考成熟项目管理器的信息层级和密度,但不得复制 Unity 等外部产品的品牌、Logo、深色皮肤、专有图标、列名或文案;页面继续使用 Genarrative 平台主题和陶泥儿客户端壳。
- 表格只展示现有权威数据:项目名称与工作区绝对路径、GameAgent / Godot 类型、目录 / manifest / 最近 Runtime 状态和行级操作。当前目录检查合同没有修改时间、编辑器版本、收藏或云状态,前端不得伪造这些列。
- 可打开项目的主行点击后进入项目;显示目录和从最近列表移除收进键盘可达的行尾更多菜单。Escape 关闭菜单并把焦点还给触发按钮;移除只修改本机 WebView 最近项目记录,不删除磁盘文件。
- 搜索只在已加载项目行中匹配名称、路径、类型、Godot 相对根和状态,不改 localStorage、不触碰项目目录、不新增后端或 Tauri 命令。无匹配状态提供清除搜索;真正空状态仍只引导用户使用顶部打开或新建,不追加第三个目录选择入口。
- 正式视觉验收只覆盖 `1280×720` 最小横屏和 `1280×800` 默认窗口:工具栏保持单行,表头与项目列对齐,项目列表内部滚动,document/body 不出现页面级横向或纵向溢出。截图必须使用含 Web、Godot 和无效状态的 populated fixture;视频必须演示搜索、清除、行尾菜单及可观察的项目操作结果,不能只录静止页面或无结果点击。
## 4. 工作台状态机
### 4.1 主视窗
@@ -7159,6 +7159,10 @@
## 2026-08-10 AGC 打开现有 Godot 项目
- 项目管理 UI 决策(2026-08-15):项目页改为 Genarrative 自有浅色视觉的紧凑桌面项目表格,成熟项目管理器只作为“标题 / 搜索 / 主操作 / 表头 / 项目行 / 行尾菜单”的信息架构参考,不复制外部品牌、皮肤、专有图标和文案。顶部只有搜索、打开项目和新建项目;搜索不是路径输入。可打开项目的主行直接进入项目,显示目录与从最近列表移除收进行尾更多菜单。
- 投影边界:表格只展示目录检查、manifest 与 Runtime 已有权威字段,包括名称、工作区路径、GameAgent / Godot 类型、`godotProjectRoot` 和状态;没有修改时间、编辑器版本、收藏、云同步或版本警告事实时不得伪造这些列。搜索只过滤当前项目行,不改 storage 或项目目录。
- 验收边界:正式项目页只按 `1280×720` 最小横屏和 `1280×800` 默认窗口验收,工具栏单行、列表内部滚动、页面无横纵溢出。截图使用 populated fixture;视频必须展示搜索、菜单和可观察操作结果,不再接受长时间静止或只有 picker 调用的录屏。窄屏 / 竖屏不属于当前产品合同。
- 项目双根决策(2026-08-14 更新):项目组只保留通用“打开项目 / 新建项目”,不再提供独立 Godot 入口。用户选择目录始终是工作区根,也是 `.agent`、Session、Runner、沙箱、通用文件工具和外围资料的唯一授权根;实际 Godot 根由根目录或一层直接子目录中的普通文件 `project.godot` 唯一确定,并以工作区相对 `godotProjectRoot` 记录,根目录使用 `.`。不得把 Runtime 根切换成 Godot 子目录,也不得复制工程或建立第二套工作区。
- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。
- 元数据决策:首次导入只在工作区根创建并保留 `.agent/manifest.json``.agent/agent.db``.agent/logs/``.agent/runtime/`;不得创建默认 Web 原型的 `game/``assets/``memory/``exports/`。已有有效 `.agent` 项目继续复用身份;缺失或错误的可推导 `godotProjectRoot` 只在 Godot 打开边界按唯一文件布局校准,歧义时不改写。
@@ -4388,9 +4388,17 @@
- 现象:源码已经移除项目页常驻路径输入框,Windows 客户端却仍显示 `/tmp/genarrative-ai-game-draft`,新“打开项目 / 新建项目”交互也没有出现。
- 原因:`apps/ai-game-creator-shell/dist/` 是 Git 忽略的本地构建产物,可能跨提交保留旧 JS;复用旧 `dist`、旧 EXE 或旧安装包时,Tauri 会继续嵌入旧前端。测试模式曾把 `/tmp` 同时当作产品初值,也让旧构建和测试夹具的边界难以辨认。
- 处理:产品前端项目页不持有默认路径;首页回车自动工作区仍只由原生 `document_dir()` 决定目标根,测试必须在 harness 内显式注入 `/tmp` fixture。普通与 game-chat release 在 Vite 构建后、Tauri 嵌入前扫描实际 `frontendDist`,命中精确旧路径、缺失产物或链接绕过时失败关闭;不能用仓库级源码扫描误伤合法测试 fixture。
- 验证:运行 frontend dist guard 定向测试、AGC AppSurface 的双主按钮 / picker 防重复 / 首页回车自动创建回归、Tauri release `--no-bundle` smoke,并确认新 `dist` 不含旧路径;Windows 实机项目组不得出现常驻路径框或 `/tmp`,原生 picker 从系统默认位置打开。视觉验收同时检查桌面和窄屏双主按钮布局
- 验证:运行 frontend dist guard 定向测试、AGC AppSurface 的双主按钮 / picker 防重复 / 首页回车自动创建回归、Tauri release `--no-bundle` smoke,并确认新 `dist` 不含旧路径;Windows 实机项目组不得出现常驻路径框或 `/tmp`,原生 picker 从系统默认位置打开。视觉验收检查 `1280×720` 最小横屏与 `1280×800` 默认窗口的紧凑项目表格和原生 picker
- 关联:`apps/ai-game-creator-shell/src/app/constants.ts``apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts``apps/ai-game-creator-shell/src-tauri/src/commands.rs``apps/ai-game-creator-shell/src-tauri/build.rs``apps/ai-game-creator-shell/src-tauri/tauri.conf.json`
## 参考成熟项目管理器不能变成品牌复刻或伪数据列(2026-08-15)
- 现象:根据外部产品截图重做项目页时,直接照搬其 Logo、深色皮肤、收藏 / 云图标、修改时间或编辑器版本列,页面看似成熟却展示 AGC 没有的数据真相,录屏也只剩静态摆拍。
- 原因:把参考截图当成完整产品合同,没有先核对当前目录检查、manifest 和 Runtime 真正提供的字段,也没有定义视频必须证明的交互结果。
- 处理:只借鉴标题、搜索、主操作、紧凑表头 / 项目行和行尾菜单的信息层级;继续使用 Genarrative theme/token,只展示项目名称、工作区路径、GameAgent / Godot 类型、Godot 相对根和真实状态。次要操作收进行尾菜单,搜索只做本地过滤。没有权威来源的列直接不做,不用占位或推断补齐。
- 验证:DOM 与截图不得出现外部品牌或 unsupported 列;AppSurface 覆盖 populated / invalid / empty、搜索与菜单;Playwright 在 `1280×720` 测量无页面级溢出。视频控制在有用时长内,清楚展示搜索、清除、菜单、状态反馈和项目打开结果,每一段都有可观察变化。
- 关联:`apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx``apps/ai-game-creator-shell/src/features/app-shell/model.ts``apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts``apps/ai-game-creator-shell/tests/appSurface/home.suite.ts`
## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03
- 现象:首波从单个美术任务扩展为三个 Director 后,hydration 若仍只容忍 seed lane 的第一个任务在 manifest 短暂恢复 `Pending` 时收束,另外两个已启动 Director 会被卡住。另外默认 `llm.stream=false` 下的专业 final reply 虽已由 finalization 提交,但后续阶段推进项目 revision 后,早期回复会从 Runtime 查询中消失。
@@ -576,7 +576,7 @@ game-project/
- 首页发送、项目组目录选择和本地文件选择必须使用 Tauri 非阻塞原生 picker,并把选择器绑定到当前 `client` 窗口;禁止在同步 command 中调用 `blocking_pick_folder` / `blocking_pick_file` 阻塞 WebView 事件循环。选择器打开期间保留首页草稿可编辑,取消后恢复“开启创作”按钮并回显“已取消”。
- 当前尚未定义 GameAgent 独立的灵感数据源;首页保留“灵感推荐”区块并显示无数据状态,但不得展示或请求主站 `/creation` 的陶泥儿精选 `/api/editor/showcase/resources`。后续接入前必须先明确独立数据契约和交互验收。
- debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、管理对应 active/archived Session 并读取历史,用户消息和真实 Agent 回复只持久化到 `.agent/conversations/agents/<agentId>/` 下的规范 Session。普通用户窗口不得出现 `Agent 聊天` 导航、picker 或工具台入口。
- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口管理最近项目,并且顶部只常驻“打开项目”“新建项目”两个主动作,不常驻路径输入框、目录显示按钮或独立 Godot 入口。两个动作都先打开原生目录选择器:“打开项目”读取已有 GameAgent 项目,或自动识别根目录 / 一层直接子目录中的唯一 Godot 工程并导入;普通未初始化目录提示改用“新建项目”。“新建项目”在用户选择工作区根后沿用非空目录确认,不自动重建无效历史路径。最近项目行继续提供打开、显示和移除;空状态不追加第三个目录选择入口。按钮在桌面端并排、窄屏下纵向全宽,状态反馈保持可访问且不暴露 Linux 测试默认路径
- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口使用紧凑桌面项目表格管理最近项目。顶部工具栏只放本地搜索、“打开项目”“新建项目”,不常驻路径输入框、目录显示按钮或独立 Godot 入口。两个项目动作都先打开绑定当前 `client` 的非阻塞原生目录选择器:“打开项目”读取已有 GameAgent 项目,或自动识别根目录 / 一层直接子目录中的唯一 Godot 工程并导入;普通未初始化目录提示改用“新建项目”。“新建项目”在用户选择工作区根后沿用非空目录确认,不自动重建无效历史路径。项目表格只投影名称、路径、GameAgent / Godot 类型、目录 / manifest / Runtime 状态;可打开行点击进入项目,显示目录和移除最近记录收进行尾更多菜单。搜索仅过滤当前行,不修改 storage;空状态不追加第三个目录选择入口。正式验收只覆盖 `1280×720` 最小横屏和 `1280×800` 默认窗口,列表内部滚动且无页面级溢出
- 项目开发页保留左侧栏和顶部栏,顶部展示项目名、路径和最近 run 状态;中间只挂载 active `project-supervisor` Session 的正式对话面、Runtime 状态、确认/Needs input 和专业 Agent 协作只读状态,底部保留附件导入结果。真正的项目开发画布仍未落地;专业 Agent picker、完整计划和工具台继续留在开发入口。
## v1 验收证据矩阵
@@ -681,7 +681,7 @@ game-project/
- 2026-07-10 补充:当前工具箱还开放 `agent.delegate`,审计记录类型为 `agent.runtime.agent.delegate`;该工具只把任务投递到目标 Agent 既有后台队列,不创建平行 runtime,不绕过目标 Agent 的锁和队列,也受 `agent.delegate` 项目权限策略保护。
- 2026-07-10 补充:当前 Runtime 命令和后台工具箱都开放 `agent.schedule_ready`,审计记录类型为 `agent.runtime.ready_task.scheduled`;该命令把 manifest ready task 批量投递到对应 Agent 的既有后台队列,默认需要确认,确认前不会启动下游 Agent。
- 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。
- 单窗口首页和项目组页可选择、打开、新建或显示当前输入项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。
- 单窗口首页和项目组页不保留常驻路径输入项目组只展示最近项目只读检查后的工作区绝对路径。打开 / 新建使用原生目录选择器,行尾菜单可显示已确认存在的项目目录,非法或相对路径不会调用系统文件管理器。
- 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。
- 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 客户端配置是否就绪;桌面 App 主窗口“配置”面板可读写 Tauri 应用配置目录中的 `game-creator.config.json``/llm-status` / 生成入口读取同一份配置,CLI 开发入口无 AppHandle 时才回退读取仓库旁边的配置模板和 gitignored 本机覆盖文件;不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。
- 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-llm` 网关测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke;已退役的 `platform-agent` 不再进入 workspace 或该门禁。
@@ -787,13 +787,13 @@ game-project/
- 聊天输入 `/internals` 只列出项目内部真相源读取命令,覆盖 `.agent/manifest.json``.agent/run.latest.json``.agent/spec.md``.agent/findings.md``.agent/policy.json``.agent/project.index.json``.agent/agent.db``.agent/conversations/project.jsonl`;该命令不调用 Tauri 读写、不读取文件、不新增普通用户内部文件面板,真正读取仍由用户发送 `/read` 并走 `file.read` 权限流。
- 聊天输入 `/publish` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、资产来源和最近命令摘要,在聊天里生成发布准备清单,列出原型通过状态、预览、任务、资产、音频、包装说明和试玩包导出状态,并提供 `/run``/trace``/agent-resume ``/export` 草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件、不新增普通用户面板,真正导出仍由用户发送 `/export` 并走确认流。
- 开发窗口可从 Agent 状态列表进入单个专业 Agent 对话并管理其 Session;正式用户项目开发页只读展示底部专业 Agent 状态和悬浮详情,不提供单 Agent 对话入口、Session 控件或调度工具台。
- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页管理最近项目、打开项目、新建项目显示目录;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。
- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页以紧凑表格管理最近项目,顶部提供搜索、打开项目、新建项目显示目录和移除最近记录进入行尾菜单;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。
- 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认,成功后同步刷新顶部余额和泥点拆分。“使用详情”统一打开 `packages/shared` 中的 `PlatformProfileWalletLedgerModal`,由各宿主分别维护打开、加载、失败重试状态并读取 `/api/profile/wallet-ledger`,共享组件只承接账单来源文案、金额与日期展示及 loading / empty / error 视图,不发请求、不持有账户事实。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。
- 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。
- 首页、项目组页和项目开发页共用单窗口壳的全局运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;正式 Supervisor 项目页缺配置时只显示错误,不自动打开该弹窗。API Key 仍不进入本地项目、trace、manifest 或聊天记录。
- 首页发送项目组新建都通过 Tauri 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。
- 最近项目列表只来自本机 WebView storage,可移除单项或清空;这些操作不触碰项目目录,也不触发窗口切换。项目组页只读检查最近项目路径,已初始化项目优先显示 `.agent/manifest.json` 里的项目名并保留路径副信息;如果 `.agent/run.latest.json` 可读,则显示最近 run 的 status / stopReason;缺失、非目录、未初始化或检查失败的条目禁用打开,用户可移除后重新选择。刷新最近项目只重新执行这组只读检查,不改项目目录或最近项目列表;刷新未完成时按钮显示“刷新中”并禁用,最近项目条目显示“检查中”且临时禁用打开 / 显示操作,避免重复触发并让用户知道检查仍在进行;“显示”只通过系统文件管理器打开已确认存在的本地目录,未初始化但存在的目录也可显示,缺失、非目录或检查失败时禁用,不初始化项目、不写最近项目
- 项目组页“打开”进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;路径不存在、不是文件夹或只是普通文件夹时留在当前页提示,不自动创建目录。“新建项目”和聊天 `project.create` 新建入口会先检查目标目录,目标目录已经存在且非空时必须弹出确认提醒;用户继续后才调用 `init_local_game_project`,默认项目名取目标文件夹名,再写入最近项目并切到项目开发页;用户取消或初始化失败时不切换视图、也不写入最近项目。
- 首页发送以及项目组打开 / 新建都通过绑定当前 `client` Tauri 非阻塞原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。
- 最近项目列表只来自本机 WebView storage,可逐行移除;该操作不触碰项目目录,也不触发窗口切换。项目组页自动只读检查最近项目路径,已初始化项目优先显示 `.agent/manifest.json` 里的项目名并保留路径副信息;如果 `.agent/run.latest.json` 可读,则 status / stopReason 转成面向用户的状态。缺失、非目录、manifest 损坏或检查失败的条目禁用打开;目录仍存在时可从行尾菜单显示,检查中临时禁用打开 / 显示。页面不恢复清空或手动刷新按钮,本地搜索也不改项目列表
- 项目组页“打开”直接进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;发现根目录或一层子目录中的唯一 Godot 工程时允许导入并进入项目。路径不存在、不是文件夹、普通未初始化目录或 Godot 歧义时留在当前页提示,且不写 `.agent`。“新建项目”和聊天 `project.create` 新建入口会先检查目标目录,目标目录已经存在且非空时必须弹出确认提醒;用户继续后才调用 `init_local_game_project`,默认项目名取目标文件夹名,再写入最近项目并切到项目开发页;用户取消或初始化失败时不切换视图、也不写入最近项目。
- 聊天输入 `/commands` 会只读列出 Tauri runtime 暴露的受限命令白名单,读取失败或非 Tauri 环境下回退到共享契约默认列表;该命令不执行白名单命令,也不要求先初始化项目。
- 聊天输入 `/smoke` 会生成待确认的 `command.run_limited` 内置命令,当前只映射到白名单 `game.static_smoke`,不开放任意命令解析。
- 聊天输入 `/run` 会生成待确认的 `game.run_local` 内置命令,确认后复用白名单 `game.static_smoke` 运行当前 `game/index.html`,通过后启动只读本地 HTTP 预览并切换到客户端内运行视图;该命令不开放任意 shell。