优化客户端主界面表现
Project CI / Repository checks (pull_request) Failing after 2m28s
Project CI / Native shell tests (pull_request) Failing after 2m29s
Project CI / Frontend tests (pull_request) Successful in 4m26s
Project CI / Backend tests (pull_request) Successful in 5m36s

调整首页品牌区、创作类型按钮和输入框视觉比例。

重做最近项目横向信息卡并展示类型、更新时间和进度。

补充本地项目修改时间只读字段及首页回归测试。

同步更新 GameAgent 客户端实施文档。
This commit is contained in:
2026-08-23 13:38:28 +08:00
parent 78ebe063f3
commit b28a301ee8
8 changed files with 112 additions and 70 deletions
@@ -393,12 +393,23 @@ pub(crate) fn inspect_local_project_directory(
is_godot_project: godot_project_root.is_some(),
godot_project_root,
project_name: game_creator_project_name(root),
modified_at: project_directory_modified_at(root),
manifest_error: game_creator_project_manifest_error(root),
recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()),
recent_run_stop_reason: recent_run_trace.map(|trace| trace.stop_reason),
})
}
fn project_directory_modified_at(root: &Path) -> Option<u64> {
root.metadata()
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
}
pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool {
if !root.is_dir() {
return false;
@@ -179,6 +179,7 @@ struct LocalProjectDirectoryStatus {
is_godot_project: bool,
godot_project_root: Option<String>,
project_name: Option<String>,
modified_at: Option<u64>,
manifest_error: Option<String>,
recent_run_status: Option<String>,
recent_run_stop_reason: Option<String>,
@@ -1397,6 +1397,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
is_godot_project: false,
godot_project_root: None,
project_name: None,
modified_at: None,
manifest_error: None,
recent_run_status: None,
recent_run_stop_reason: None,
@@ -1412,6 +1413,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
assert!(!file_status.is_godot_project);
assert_eq!(file_status.godot_project_root, None);
assert_eq!(file_status.project_name, None);
assert!(file_status.modified_at.is_some());
assert_eq!(file_status.manifest_error, None);
assert_eq!(file_status.recent_run_status, None);
fs::remove_file(&root).expect("remove file");
@@ -96,6 +96,7 @@ export interface LocalProjectDirectoryStatus {
isGodotProject: boolean;
godotProjectRoot: string | null;
projectName: string | null;
modifiedAt?: number | null;
manifestError?: string | null;
recentRunStatus: string | null;
recentRunStopReason: string | null;
@@ -61,6 +61,7 @@ export type RecentProjectRow = {
status: string;
projectKind: 'web' | 'godot' | 'unknown';
godotProjectRoot: string | null;
modifiedAt: number | null;
recentRunStatus: string | null;
recentRunStopReason: string | null;
canReveal: boolean;
@@ -262,6 +263,7 @@ export function buildRecentProjectRows(
? 'web'
: 'unknown',
godotProjectRoot: directoryStatus?.godotProjectRoot ?? null,
modifiedAt: directoryStatus?.modifiedAt ?? null,
recentRunStatus: directoryStatus?.recentRunStatus ?? null,
recentRunStopReason: directoryStatus?.recentRunStopReason ?? null,
canReveal,
@@ -5,8 +5,8 @@ import {
Gamepad2,
Image,
type LucideIcon,
Plus,
Sparkles,
ArrowUp,
} from 'lucide-react';
import type { FormEvent } from 'react';
import { useRef, useState } from 'react';
@@ -33,7 +33,6 @@ type HomeCreationTypeItem = {
creationType: HomeCreationType;
label: string;
placeholder: string;
emptyPrompt: string;
icon: LucideIcon;
};
@@ -42,21 +41,18 @@ const HOME_CREATION_TYPE_ITEMS: readonly HomeCreationTypeItem[] = [
creationType: 'game',
label: '做游戏',
placeholder: '今天想把什么灵感做成游戏',
emptyPrompt: '请输入游戏灵感或上传参考素材',
icon: Gamepad2,
},
{
creationType: 'art',
label: '做素材',
placeholder: '今天想做什么样的美术素材',
emptyPrompt: '请输入素材需求或上传参考图',
icon: Image,
},
{
creationType: 'doc',
label: '做方案',
placeholder: '今天有什么设计需要帮你整理',
emptyPrompt: '请输入方案需求或上传资料',
icon: FileText,
},
];
@@ -65,9 +61,30 @@ export type HomeProjectRow = {
path: string;
name: string;
status: string;
projectKind: 'web' | 'godot' | 'unknown';
modifiedAt: number | null;
canOpen: boolean;
};
function formatProjectUpdatedAt(modifiedAt: number | null) {
if (!modifiedAt || !Number.isFinite(modifiedAt) || modifiedAt <= 0) {
return '未记录';
}
const date = new Date(modifiedAt);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}/${month}/${day}`;
}
function projectTypeLabel(projectKind: HomeProjectRow['projectKind']) {
return projectKind === 'godot'
? 'Godot 游戏'
: projectKind === 'web'
? 'Web 游戏'
: '本地项目';
}
type HomeViewProps = {
hasPromo: boolean;
status: string;
@@ -113,7 +130,6 @@ export default function HomeView({
const referencedAttachments = richTextToAttachments(homeRichText);
const prompt = richTextToPrompt(homeRichText);
if (!prompt && referencedAttachments.length === 0) {
onStatusChange(activeCreationType.emptyPrompt);
return;
}
homeCreationBusyRef.current = true;
@@ -143,26 +159,30 @@ export default function HomeView({
return (
<div className="platform-theme platform-theme--light min-h-screen bg-(image:--platform-body-fill) text-(--platform-text-strong)">
<section
className={`grid min-h-93.5 content-start justify-items-center gap-3.25 px-6 pb-4.5 pt-25.75 max-[760px]:min-h-0 max-[760px]:gap-4 max-[760px]:px-3.5 max-[760px]:pb-8.5 max-[760px]:pt-21 ${
hasPromo ? 'pt-33.75 max-[760px]:pt-21' : ''
className={`grid content-start justify-items-center gap-3.5 px-6 pb-4.5 pt-20 max-[760px]:gap-4 max-[760px]:px-3.5 max-[760px]:pb-8.5 max-[760px]:pt-21 ${
hasPromo ? 'pt-28 max-[760px]:pt-21' : ''
}`}
aria-label="首页输入"
>
<div className="flex items-center gap-3 text-center max-[760px]:flex-col max-[760px]:gap-2.5">
<span className="grid size-32 shrink-0 place-items-center rounded-full shadow-(--platform-profile-action-shadow)">
<img src={BRAND_ICON} alt="logo" className="size-32" />
<div className="flex w-[min(814px,calc(100vw-122px))] items-center gap-4 text-left max-[760px]:w-[min(100%,calc(100vw-76px))] max-[760px]:flex-col max-[760px]:items-start max-[760px]:gap-2.5">
<span className="grid size-30 shrink-0 place-items-center rounded-full bg-(--platform-subpanel-fill) shadow-(--platform-profile-action-shadow) max-[760px]:size-27">
<img
src={BRAND_ICON}
alt="logo"
className="size-30 max-[760px]:size-27"
/>
</span>
<div>
<h1 className="m-0 text-[27px] font-[750] tracking-normal text-(--platform-brand-logo-title) max-[760px]:text-[22px]">
<h1 className="m-0 text-[36px] font-[750] tracking-[-0.03em] text-(--platform-brand-logo-title) max-[760px]:text-[33px]">
</h1>
<p className="m-0 mt-3 text-[13px] text-(--platform-text-soft)">
{activeCreationType.placeholder}
<p className="m-0 mt-1.5 text-[19.5px] font-medium text-(--platform-warm-text)">
</p>
</div>
</div>
<div
className="flex w-[min(488px,calc(100vw-110px))] flex-wrap justify-center gap-2.25 max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="flex w-[min(814px,calc(100vw-122px))] flex-wrap justify-start gap-3 max-[760px]:w-[min(100%,calc(100vw-76px))]"
role="group"
aria-label="创作类型"
>
@@ -171,10 +191,10 @@ export default function HomeView({
const isActive = item.creationType === homeCreationType;
return (
<button
className={`inline-flex min-h-6.75 cursor-pointer items-center gap-1.25 rounded-full border px-3 text-[12px] ${
className={`inline-flex min-h-7 cursor-pointer items-center gap-1.25 rounded-full border px-4 text-[12px] font-medium transition-colors ${
isActive
? 'border-(--platform-nav-active-border) bg-(image:--platform-nav-active-fill) text-(--platform-warm-text) shadow-(--platform-nav-active-shadow)'
: 'border-(--platform-subpanel-border) bg-(image:--platform-subpanel-fill) text-(--platform-neutral-text)'
? 'border-(--platform-button-primary-border) bg-(image:--platform-button-primary-fill) text-(--platform-button-primary-text) shadow-(--platform-nav-active-shadow)'
: 'border-(--platform-subpanel-border) bg-(image:--platform-subpanel-fill) text-(--platform-neutral-text) hover:border-(--platform-nav-active-border) hover:text-(--platform-warm-text)'
}`}
type="button"
key={item.creationType}
@@ -187,11 +207,8 @@ export default function HomeView({
);
})}
</div>
<span className="text-[12px] text-(--platform-text-soft)">
{status}
</span>
<form
className="grid min-h-20 w-[min(1054px,calc(100vw-122px))] grid-rows-[minmax(42px,auto)_auto] rounded-[18px] border border-(--platform-surface-border) bg-(--platform-input-fill) px-3 pb-2 pt-3.25 shadow-(--platform-panel-shadow) focus-within:bg-(--platform-input-fill-focus) focus-within:ring-4 focus-within:ring-(--platform-input-focus-ring) max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="grid min-h-26 w-[min(814px,calc(100vw-122px))] grid-rows-[minmax(62px,auto)_auto] rounded-[16px] border border-(--platform-surface-border) bg-(--platform-input-fill) px-3.5 pb-2.5 pt-3.5 shadow-(--platform-panel-shadow) focus-within:bg-(--platform-input-fill-focus) focus-within:ring-4 focus-within:ring-(--platform-input-focus-ring) max-[760px]:w-[min(100%,calc(100vw-76px))]"
onSubmit={handleHomeSubmit}
>
<RichInputArea
@@ -205,20 +222,25 @@ export default function HomeView({
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
<UploadButton />
<button
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) disabled:cursor-not-allowed disabled:opacity-55"
className="grid size-7 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) transition-transform hover:scale-105 disabled:cursor-not-allowed disabled:opacity-55"
type="submit"
aria-label="开启创作"
disabled={homeCreationBusy}
>
<Plus size={16} aria-hidden="true" />
<ArrowUp size={16} aria-hidden="true" />
</button>
</div>
</RichInputArea>
</form>
{status ? (
<span className="w-[min(814px,calc(100vw-122px))] text-left text-[12px] text-(--platform-text-soft) max-[760px]:w-[min(100%,calc(100vw-76px))]">
{status}
</span>
) : null}
</section>
<section
className="mx-auto grid w-[min(1054px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="mx-auto grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="最近项目"
>
<header className="flex items-center justify-between gap-3">
@@ -226,9 +248,6 @@ export default function HomeView({
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
</h2>
<p className="mb-0 mt-1 text-[12px] text-(--platform-text-soft)">
</p>
</div>
<div className="launcher-project-list-actions">
<button
@@ -249,27 +268,37 @@ export default function HomeView({
</div>
</header>
{recentProjectRows.length > 0 ? (
<div className="grid grid-cols-5 gap-3 overflow-hidden max-[760px]:grid-cols-1 max-[760px]:overflow-visible">
<div className="grid grid-cols-3 gap-4 overflow-hidden max-[760px]:grid-cols-1 max-[760px]:overflow-visible">
{recentProjectRows.map((project) => (
<article className="grid min-w-0" key={project.path}>
<button
className="grid h-auto w-full cursor-pointer content-start justify-items-start gap-1.5 overflow-hidden rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-3 text-left shadow-(--platform-panel-shadow) transition hover:border-(--platform-surface-hover-border) disabled:cursor-not-allowed disabled:opacity-[0.58] max-[760px]:h-33"
className="grid min-h-34 w-full cursor-pointer grid-cols-[108px_minmax(0,1fr)] items-stretch gap-3 overflow-hidden rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-2 text-left shadow-(--platform-panel-shadow) transition hover:border-(--platform-surface-hover-border) hover:shadow-(--platform-profile-action-shadow) disabled:cursor-not-allowed disabled:opacity-[0.58] max-[760px]:grid-cols-[120px_minmax(0,1fr)]"
type="button"
disabled={!project.canOpen}
onClick={() => onProjectOpen(project.path)}
>
<span className="grid size-10.5 place-items-center rounded-xl bg-(--platform-warm-bg) text-[16px] font-bold text-(--platform-warm-text)">
<span className="grid min-h-29 place-items-center rounded-lg bg-[linear-gradient(145deg,#f9bf7d,#e96831_52%,#853c2c)] text-[27px] font-bold tracking-[-0.08em] text-white shadow-inner">
{project.name.slice(0, 2)}
</span>
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-medium text-(--platform-text-strong)">
{project.name}
</strong>
<small className="-mt-1.25 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-(--platform-text-soft)">
{project.path}
</small>
<small className="-mt-1.25 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-(--platform-text-soft)">
{project.status}
</small>
<span className="grid min-w-0 content-center gap-2.25 py-1">
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[14px] font-semibold leading-4.5 text-(--platform-text-strong)">
{project.name}
</strong>
<small className="grid grid-cols-[36px_minmax(0,1fr)] gap-x-3 gap-y-1 text-[10px] leading-3.5 text-(--platform-text-soft)">
<span className="whitespace-nowrap"></span>
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
{projectTypeLabel(project.projectKind)}
</span>
<span className="whitespace-nowrap"></span>
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
{formatProjectUpdatedAt(project.modifiedAt)}
</span>
<span className="whitespace-nowrap"></span>
<span className="truncate whitespace-nowrap text-(--platform-warm-text)">
{project.status}
</span>
</small>
</span>
</button>
</article>
))}
@@ -296,29 +325,23 @@ export default function HomeView({
</section>
<section
className="mx-auto mt-5.5 grid w-[min(1054px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="灵感推荐"
>
<header className="flex items-center justify-start gap-3">
<div className="grid gap-1">
<span className="inline-flex items-center gap-1.5">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
</h2>
<Sparkles
className="text-(--platform-warm-text)"
size={15}
aria-hidden="true"
/>
</span>
<p className="mb-0 mt-1 text-[12px] text-(--platform-text-soft)">
</p>
</div>
<span className="inline-flex items-baseline gap-1.5">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
</h2>
<Sparkles
className="text-(--platform-warm-text)"
size={15}
aria-hidden="true"
style={{ transform: 'translateY(1px)' }}
/>
</span>
</header>
<div className="grid min-h-24 place-items-center overflow-hidden rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) text-[11px] text-(--platform-text-soft)">
</div>
<div className="min-h-24 overflow-hidden rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill)" />
</section>
</div>
);
@@ -26,12 +26,12 @@ import {
} from './harness';
export function registerClientHomeTests() {
it('keeps the empty inspiration section without reading the main-site showcase', async () => {
it('keeps the empty inspiration module without requesting the retired feed', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
renderLauncherAt('/?launcher');
const inspirationSection = screen.getByLabelText('灵感推荐');
expect(within(inspirationSection).getAllByText('暂无灵感')).toHaveLength(2);
expect(screen.getByLabelText('灵感推荐')).not.toBeNull();
expect(screen.queryByText('暂无灵感')).toBeNull();
await act(async () => {
await Promise.resolve();
});
@@ -1315,19 +1315,21 @@ export function registerHomeProjectCreationTests() {
expect(gameType.getAttribute('aria-pressed')).toBe('true');
expect(artType.getAttribute('aria-pressed')).toBe('false');
expect(documentType.getAttribute('aria-pressed')).toBe('false');
expect(screen.getAllByText('今天想把什么灵感做成游戏')).toHaveLength(2);
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
expect(screen.getAllByText('今天想把什么灵感做成游戏')).toHaveLength(1);
fireEvent.click(documentType);
expect(documentType.getAttribute('aria-pressed')).toBe('true');
expect(screen.getAllByText('今天有什么设计需要帮你整理')).toHaveLength(2);
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
expect(screen.getAllByText('今天有什么设计需要帮你整理')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
expect(await screen.findByText('请输入方案需求或上传资料')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'create_automatic_local_game_project',
);
fireEvent.click(artType);
expect(gameType.getAttribute('aria-pressed')).toBe('false');
expect(artType.getAttribute('aria-pressed')).toBe('true');
expect(screen.getAllByText('今天想做什么样的美术素材')).toHaveLength(2);
expect(screen.getByText('你的游戏创作管家')).not.toBeNull();
expect(screen.getAllByText('今天想做什么样的美术素材')).toHaveLength(1);
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '你好,今天多少号';
@@ -1494,9 +1496,7 @@ export function registerHomeProjectCreationTests() {
});
expect((createButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByLabelText('陶泥儿首页对话')).toBeNull();
expect(screen.getByLabelText('最近项目').textContent).toContain(
'选择一个项目继续创作',
);
expect(screen.getByLabelText('最近项目').textContent).toContain('最近项目');
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
'正在创建工作区',
);
@@ -662,7 +662,9 @@ game-project/
- Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单、`.agent/run.latest.json` / `.agent/runs/<runId>.json` 的 step、taskGraph、passPlans、lifecycleStatus,以及 `read_game_creator_agent_runtimes` 批量读取的 `.agent/runtime/agents/<taskId>.json` 和最近任务派生;v1 不新增独立状态数据库,也不承诺完整后台 runner。
- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。普通 `Enter` 在输入法组合态结束后自动在系统“文档/Genarrative GameAgent”下原子分配唯一工作区、初始化本地项目、导入附件,并把首条需求直接投递给 active `project-supervisor` Session 的后台 Runtime,随后写入最近项目并切到项目开发页;`Shift+Enter` 保留换行。Windows 使用系统 Documents 路径,Linux 使用 XDG DocumentsmacOS 使用用户 Documents;前端不得显示或持久化 `/tmp` 作为默认项目路径,精确 `/tmp/genarrative-ai-game-draft` 只允许由测试显式注入为 fixture。普通与 game-chat release 都必须在 Vite 生成最新 `frontendDist` 后、Tauri 嵌入资源前扫描实际构建产物;命中该旧 Linux 默认路径或无法安全遍历产物时构建失败,禁止复用 gitignored 的旧 `dist`。原“开启创作”加号按钮保持手动选择目录:点击后弹出原生目录选择,目标目录存在且非空时必须二次确认,再调用 `init_local_game_project` 完成同一后续链路。项目页和首页通用“打开项目”没有手填默认路径;picker 未携带已有工作区时由操作系统决定初始位置。缺少 active Session 时先创建并激活;成功后清空首页草稿,取消或创建失败时在首页回显状态,首页响应式断点与应用外壳统一为 `760px`。两条流程都不调用 `generate_local_game_draft``generate_platform_art_asset`、一次性 `chat_with_game_creator_agent` 或 legacy 项目对话 append。
- 首页发送、项目组目录选择和本地文件选择必须使用 Tauri 非阻塞原生 picker,并把选择器绑定到当前 `client` 窗口;禁止在同步 command 中调用 `blocking_pick_folder` / `blocking_pick_file` 阻塞 WebView 事件循环。选择器打开期间保留首页草稿可编辑,取消后恢复“开启创作”按钮并回显“已取消”。
- 当前尚未定义 GameAgent 独立的灵感数据源;首页保留“灵感推荐”区块并显示无数据状态,但不得展示或请求主站 `/creation` 的陶泥儿精选 `/api/editor/showcase/resources`。后续接入前必须先明确独立数据契约和交互验收。
- 当前尚未定义 GameAgent 独立的灵感数据源;首页保留“灵感推荐”模块及空白内容容器,但不显示无数据文案,也不得展示或请求主站 `/creation` 的陶泥儿精选 `/api/editor/showcase/resources`。后续接入前必须先明确独立数据契约和交互验收。
- 首页正式桌面版以约 `814px` 的居中内容栏组织品牌区、创作类型、输入框、最近项目与灵感推荐;品牌区和类型按钮在内容栏左缘对齐,输入框和下方模块仍保持整体居中。标题使用深色暖橙层级,固定副标题为“你的游戏创作管家”;选中的创作类型使用实心暖橙按钮,未选中项使用浅色描边,输入框使用更舒展的单行创作起始比例。
- 最近项目固定展示至多三个横向信息卡:左侧为本地渐变封面占位,右侧只投影项目名称、项目类型、目录真实修改时间和已有最近运行状态。目录检查通过 `modifiedAt` 提供修改时间;没有封面资产时不得假装读取了项目封面,也不新增封面持久化字段。
- debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、管理对应 active/archived Session 并读取历史,用户消息和真实 Agent 回复只持久化到 `.agent/conversations/agents/<agentId>/` 下的规范 Session。普通用户窗口不得出现 `Agent 聊天` 导航、picker 或工具台入口。
- 首页最近项目只展示最近 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、完整计划和工具台继续留在开发入口。