diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index de9abe72a..f783c2693 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -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(null); + const triggerRef = useRef(null); + + useLayoutEffect(() => { + if (!open) { + setDropUp(false); + return; + } + const trigger = triggerRef.current; + const menu = menuRef.current?.querySelector( + '.launcher-project-more-menu', + ); + const table = trigger?.closest('.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 ( +
+ + {open ? ( +
+ + +
+ ) : null} +
+ ); +} + 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 (
-
-
-

项目组

+
+
+

项目

{recentProjects.recentWorkspaceRefreshing ? '正在检查项目状态' : status}

-
-
- + ) : null} + +
+ - + -
-
- {recentProjects.projectRows.length > 0 ? ( - recentProjects.projectRows.map((project) => ( -
- - {project.status} - - - -
- )) - ) : ( -
-
- )} +
+
+
+
+ 项目 + 类型 + 状态 + 操作 +
+
+ {rows.length > 0 ? ( + rows.map((project) => ( +
+ + + {projectKindLabel(project)} + + + {projectStatusTone(project) === 'warning' ? ( + + +
+ )) + ) : hasStoredProjects ? ( +
+
+ ) : ( +
+
+ )} +
); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 13fd90fb8..f4fdf89aa 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -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; +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 9475b7cfb..ba71d0520 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -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>) { 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>) { 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), diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 9c97800d6..0ae68b823 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -281,6 +281,25 @@ textarea { padding: 118px 0 0; } +.launcher-projects-page { + align-content: start; + grid-template-rows: auto minmax(0, 1fr); + gap: 18px; + width: calc(100vw - var(--launcher-sidebar-width)); + max-width: none; + height: 100dvh; + min-height: 0; + margin: 0; + padding: 92px 28px 24px; + overflow: hidden; +} + +.launcher-main:has(.launcher-projects-page) { + height: 100dvh; + min-height: 0; + padding-bottom: 0; +} + .launcher-page header { display: flex; align-items: flex-end; @@ -315,10 +334,8 @@ textarea { gap: 8px; } -.launcher-project-actions button, .launcher-page-actions button, .launcher-page .launcher-project-list-actions button, -.launcher-project-table article > button, .launcher-empty-projects button { display: inline-flex; align-items: center; @@ -336,51 +353,104 @@ textarea { cursor: pointer; } -.launcher-project-actions button:disabled, .launcher-page .launcher-project-list-actions button:disabled, -.launcher-project-table article > button:disabled { +.launcher-empty-projects button:disabled { cursor: not-allowed; opacity: 0.55; } -.launcher-project-actions button:focus-visible, -.launcher-project-table article > button:focus-visible { +.launcher-projects-page button:focus-visible, +.launcher-project-search input:focus-visible { outline: 3px solid var(--platform-warm-text); outline-offset: 2px; } -.launcher-project-actions { +.launcher-projects-toolbar { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin: 16px 0 14px; + grid-template-columns: minmax(160px, 1fr) auto; + align-items: center; + gap: 24px; +} + +.launcher-projects-heading { + min-width: 0; +} + +.launcher-projects-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.launcher-project-search { + display: flex; + align-items: center; + width: clamp(260px, 28vw, 390px); + height: 38px; + gap: 8px; + padding: 0 10px; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + background: var(--platform-input-fill); + color: var(--platform-text-soft); +} + +.launcher-project-search:focus-within { + border-color: var(--platform-surface-hover-border); + box-shadow: 0 0 0 3px var(--platform-input-focus-ring); +} + +.launcher-project-search input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--platform-text-strong); + font: inherit; + font-size: 13px; +} + +.launcher-project-search button { + display: grid; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--platform-text-soft); + cursor: pointer; + place-items: center; +} + +.launcher-project-actions { + display: flex; + align-items: center; + gap: 8px; } .launcher-project-actions button { - justify-content: flex-start; - min-height: 76px; - padding: 14px 16px; - text-align: left; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + gap: 7px; + padding: 0 14px; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + cursor: pointer; } -.launcher-project-actions button > span { - display: grid; - gap: 4px; -} - -.launcher-project-actions button strong { - font-size: 14px; -} - -.launcher-project-actions button small { - color: var(--platform-text-soft); - font-size: 11px; - font-weight: 500; - white-space: normal; +.launcher-project-actions button:disabled { + cursor: not-allowed; + opacity: 0.55; } .launcher-project-actions .launcher-project-open-action { - border-color: var(--platform-surface-border); background: var(--platform-button-secondary-fill); color: var(--platform-button-secondary-text); } @@ -391,47 +461,119 @@ textarea { color: var(--platform-button-primary-text); } -.launcher-project-actions .launcher-project-create-action small { - color: inherit; - opacity: 0.78; -} - .launcher-project-page-status { min-height: 18px; } +.launcher-project-list-shell { + display: grid; + grid-template-rows: 42px minmax(0, 1fr); + min-height: 0; + overflow: hidden; + border: 1px solid var(--platform-surface-border); + border-radius: 12px; + background: var(--platform-subpanel-fill); + box-shadow: var(--platform-panel-shadow); +} + +.launcher-project-table-header, +.launcher-project-table article { + display: grid; + grid-template-columns: + minmax(360px, 1fr) minmax(120px, 160px) minmax(150px, 190px) + 56px; + align-items: center; + min-width: 0; +} + +.launcher-project-table-header { + height: 42px; + padding: 0 14px; + border-bottom: 1px solid var(--platform-surface-border); + background: color-mix( + in srgb, + var(--platform-subpanel-fill) 82%, + var(--platform-warm-bg) + ); + color: var(--platform-text-soft); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.03em; +} + +.launcher-project-table-header span:last-child { + text-align: center; +} + .launcher-project-table { display: grid; - gap: 8px; + align-content: start; + min-height: 0; + overflow-y: auto; } .launcher-project-table article { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto auto auto; - align-items: center; - gap: 10px; - min-height: 62px; - padding: 10px 12px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: #fff; + min-height: 66px; + padding: 0 14px; + border-bottom: 1px solid var(--platform-surface-border); + background: var(--platform-subpanel-fill); + transition: background 120ms ease; +} + +.launcher-project-table article:last-child { + border-bottom: 0; +} + +.launcher-project-table + article:has(.launcher-project-row-main:not(:disabled)):hover { + background: var(--platform-nav-item-hover-fill); } .launcher-project-table article > .launcher-project-row-main { - display: grid; - justify-items: start; - height: auto; + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + height: 100%; min-width: 0; + gap: 12px; padding: 0; border: 0; background: transparent; + color: inherit; text-align: left; + cursor: pointer; +} + +.launcher-project-table article > .launcher-project-row-main:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.launcher-project-kind-icon { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + border-radius: 9px; + background: var(--platform-warm-bg); + color: var(--platform-warm-text); + place-items: center; +} + +.launcher-project-name-cell { + display: grid; + min-width: 0; + gap: 4px; } .launcher-project-table article strong { display: block; - color: #111827; - font-size: 14px; + overflow: hidden; + color: var(--platform-text-strong); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; } .launcher-project-table article small { @@ -439,29 +581,135 @@ textarea { width: 100%; min-width: 0; max-width: 100%; - margin-top: 4px; - color: #8b8b8b; - font-size: 12px; + margin: 0; + color: var(--platform-text-soft); + font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.launcher-project-table article span { - color: #6b7280; +.launcher-project-kind-cell { + overflow: hidden; + padding-right: 16px; + color: var(--platform-text-base); font-size: 12px; + text-overflow: ellipsis; white-space: nowrap; } +.launcher-project-status { + display: inline-flex; + align-items: center; + justify-self: start; + max-width: calc(100% - 12px); + min-height: 24px; + gap: 5px; + overflow: hidden; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.launcher-project-status-ready { + background: color-mix(in srgb, #2d9d62 12%, transparent); + color: #237b4d; +} + +.launcher-project-status-checking { + background: color-mix(in srgb, var(--platform-warm-text) 12%, transparent); + color: var(--platform-warm-text); +} + +.launcher-project-status-warning { + background: color-mix(in srgb, #d15f45 12%, transparent); + color: #a64735; +} + +.launcher-project-more { + position: relative; + display: grid; + justify-self: center; +} + +.launcher-project-more-trigger { + display: grid; + width: 34px; + height: 34px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--platform-text-soft); + cursor: pointer; + place-items: center; +} + +.launcher-project-more-trigger:hover, +.launcher-project-more-trigger[aria-expanded='true'] { + background: var(--platform-nav-item-hover-fill); + color: var(--platform-text-strong); +} + +.launcher-project-more-menu { + position: absolute; + top: 38px; + right: 0; + z-index: 20; + display: grid; + width: 164px; + gap: 4px; + padding: 6px; + border: 1px solid var(--platform-surface-border); + border-radius: 10px; + background: var(--platform-subpanel-fill); + box-shadow: 0 16px 34px rgb(75 47 32 / 18%); +} + +.launcher-project-more-menu-drop-up { + top: auto; + bottom: 38px; +} + +.launcher-project-more-menu button { + display: flex; + align-items: center; + justify-content: flex-start; + min-height: 34px; + gap: 8px; + padding: 0 9px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.launcher-project-more-menu button:hover { + background: var(--platform-nav-item-hover-fill); +} + +.launcher-project-more-menu button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.launcher-project-more-menu .launcher-project-remove-action { + color: #a64735; +} + .launcher-empty-projects { display: grid; justify-items: center; gap: 12px; - min-height: 220px; - padding: 44px; - border: 1px dashed #d8dde5; - border-radius: 8px; - color: #6b7280; + min-height: 260px; + padding: 48px; + color: var(--platform-text-soft); } .launcher-empty-projects strong { @@ -2711,59 +2959,13 @@ textarea { white-space: nowrap; } - .launcher-project-actions { - grid-template-columns: 1fr; - gap: 9px; - } - - .launcher-project-actions button { - min-height: 66px; - } - .launcher-page { width: min(100%, calc(100vw - 76px)); } .launcher-projects-page { - padding-top: 54px; - } - - .launcher-project-table article { - grid-template-columns: 1fr 1fr; - align-items: stretch; - gap: 8px; - } - - .launcher-project-table article > .launcher-project-row-main, - .launcher-project-table article > span { - grid-column: 1 / -1; - } - - .launcher-project-table article > .launcher-project-row-main { - width: 100%; - max-width: 100%; - } - - .launcher-project-table article > .launcher-project-row-main strong { - width: 100%; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .launcher-project-table article > span { - white-space: normal; - } - - .launcher-project-table article > button:not(.launcher-project-row-main) { - width: 100%; - min-width: 0; - padding: 0 8px; - } - - .launcher-project-table article > button:last-child { - grid-column: 1 / -1; + width: calc(100vw - var(--launcher-sidebar-width)); + padding: 72px 18px 20px; } .launcher-agent-chat-page > header, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 2c7d570ff..13fc0e0b4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -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) => { + 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(); }); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 1b5d02724..55b058981 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -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 主视窗 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 678525d25..fae730801 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 打开边界按唯一文件布局校准,歧义时不改写。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fcffd6da9..d34d969e6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 查询中消失。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7bdfeac49..025732101 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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//` 下的规范 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。