合并 origin/master 到 Game Agent 读取路径分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Successful in 4m8s
Project CI / Frontend tests (pull_request) Successful in 2m17s
Project CI / Native shell tests (pull_request) Successful in 6m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m7s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m16s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m38s
Project CI / Repository checks (pull_request) Successful in 2m28s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Successful in 4m8s
Project CI / Frontend tests (pull_request) Successful in 2m17s
Project CI / Native shell tests (pull_request) Successful in 6m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m7s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m16s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m38s
Project CI / Repository checks (pull_request) Successful in 2m28s
- 决策记录同时保留外壳状态栏浮层与 Game Agent 读取路径两条 2026-09-29 条目
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { LauncherNoticeTone } from './model';
|
||||
|
||||
/**
|
||||
* 一次性提示的时长:成功一闪而过就够;失败要留得够久,用户得看清是什么没跑起来。
|
||||
*
|
||||
* 「运行 / 预览失败」这类错误已经不再写对话区(那里只保留对话内容),所以这枚 toast 是它
|
||||
* 唯一的出口——2.6 秒对错误太短。「已取消」这类回显也走同一套时长:它是「什么都没发生」,
|
||||
* 不需要用户回头再看。承接原状态栏的状态不在这张表里,它们走 `sticky`。
|
||||
*/
|
||||
const LAUNCHER_NOTICE_MILLIS: Record<LauncherNoticeTone, number> = {
|
||||
success: 2600,
|
||||
neutral: 2600,
|
||||
error: 6000,
|
||||
};
|
||||
|
||||
export type LauncherNotice = {
|
||||
/** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */
|
||||
id: number;
|
||||
message: string;
|
||||
tone?: LauncherNoticeTone;
|
||||
/**
|
||||
* 粘性提示不自动收起,直到被下一条提示替换。
|
||||
*
|
||||
* 原来那行状态栏(进行中进度、失败结论)就是这种生存期:它要能被回头看见,不能自己消失;
|
||||
* 只有「已取消」这类一次性回显才到点收起。
|
||||
*/
|
||||
sticky?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 外壳级浮层提示。
|
||||
*
|
||||
* 承接三类反馈,页面上都不留文字:
|
||||
* 1. 运行 / 预览类动作——以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面;
|
||||
* 运行页的预览地址改用顶栏的「在浏览器打开」。
|
||||
* 2. 状态栏里的状态(进行中进度、失败结论、成功回显)——以前常驻在首页输入框下方和项目组页面
|
||||
* 头部那一行;现在走粘性浮层,语义不变(被下一条状态替换或清空时才消失)。
|
||||
* 3. 「已取消」这类一次性回显——到点自动收起。
|
||||
*/
|
||||
export function LauncherNoticeToast({
|
||||
notice,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: LauncherNotice | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
/**
|
||||
* 收起回调只从 ref 读:外壳每渲染一次就会换一个内联箭头函数,把它写进 effect 依赖会
|
||||
* 让计时器在每次重渲染时重新起算——长驻页面上的提示就永远收不回去了。
|
||||
*/
|
||||
const onDismissRef = useRef(onDismiss);
|
||||
onDismissRef.current = onDismiss;
|
||||
useEffect(() => {
|
||||
if (!notice || notice.sticky) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(
|
||||
() => onDismissRef.current(),
|
||||
LAUNCHER_NOTICE_MILLIS[notice.tone ?? 'success'],
|
||||
);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [notice]);
|
||||
|
||||
if (!notice) {
|
||||
return null;
|
||||
}
|
||||
return createPortal(
|
||||
<div
|
||||
key={notice.id}
|
||||
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] w-max max-w-[min(680px,calc(100vw-2rem))] -translate-x-1/2"
|
||||
data-launcher-notice-toast="true"
|
||||
>
|
||||
<PlatformRuntimeStatusToast
|
||||
tone={notice.tone ?? 'success'}
|
||||
surface="solid"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{notice.message}
|
||||
</PlatformRuntimeStatusToast>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -180,11 +180,9 @@ function ProjectMoreMenu({
|
||||
}
|
||||
|
||||
export function ProjectsPage({
|
||||
status,
|
||||
homeProject,
|
||||
recentProjects,
|
||||
}: {
|
||||
status: string;
|
||||
homeProject: HomeProjectCreationController;
|
||||
recentProjects: RecentProjectsController;
|
||||
}) {
|
||||
@@ -249,11 +247,6 @@ export function ProjectsPage({
|
||||
<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>
|
||||
<div className="launcher-projects-controls">
|
||||
<label className="launcher-project-search">
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { ProjectRunNotice } from './model';
|
||||
|
||||
/**
|
||||
* 成功提示一闪而过就够;失败提示要留得够久,用户得看清是什么没跑起来。
|
||||
*
|
||||
* 「运行 / 预览失败」这类错误已经不再写对话区(那里只保留对话内容),所以这枚 toast 是它
|
||||
* 唯一的出口——2.6 秒对错误太短。
|
||||
*/
|
||||
const RUN_NOTICE_MILLIS: Record<'success' | 'error', number> = {
|
||||
success: 2600,
|
||||
error: 6000,
|
||||
};
|
||||
|
||||
export type RunNotice = ProjectRunNotice & {
|
||||
/** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */
|
||||
id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行 / 预览类动作的浮层提示。
|
||||
*
|
||||
* 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面;
|
||||
* 现在统一走 toast,对话区只保留对话内容——运行页的预览地址改用顶栏的「在浏览器打开」。
|
||||
*/
|
||||
export function RunNoticeToast({
|
||||
notice,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: RunNotice | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(
|
||||
onDismiss,
|
||||
RUN_NOTICE_MILLIS[notice.tone ?? 'success'],
|
||||
);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [notice, onDismiss]);
|
||||
|
||||
if (!notice) {
|
||||
return null;
|
||||
}
|
||||
return createPortal(
|
||||
<div
|
||||
key={notice.id}
|
||||
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] -translate-x-1/2"
|
||||
data-project-run-notice-toast="true"
|
||||
>
|
||||
<PlatformRuntimeStatusToast
|
||||
tone={notice.tone ?? 'success'}
|
||||
surface="solid"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{notice.message}
|
||||
</PlatformRuntimeStatusToast>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -42,9 +42,16 @@ import { projectPathsMatchForInvalidation } from '../project-summary/projectPath
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import type { ProjectRunNotice, WorkspaceLauncherShellProps } from './model';
|
||||
import {
|
||||
type LauncherNotice,
|
||||
LauncherNoticeToast,
|
||||
} from './LauncherNoticeToast';
|
||||
import type {
|
||||
ProjectRunNotice,
|
||||
ShowTransientNotice,
|
||||
WorkspaceLauncherShellProps,
|
||||
} from './model';
|
||||
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
|
||||
import { type RunNotice, RunNoticeToast } from './RunNoticeToast';
|
||||
import { useAccountWallet } from './useAccountWallet';
|
||||
import {
|
||||
DESIGN_ARTIFACTS_BUILD_PROMPT,
|
||||
@@ -81,12 +88,40 @@ export function WorkspaceLauncherShell({
|
||||
message: string;
|
||||
} | null>(null);
|
||||
/**
|
||||
* 运行 / 预览类动作的浮层提示。
|
||||
* 外壳级一次性浮层提示(运行 / 预览反馈、取消类回显)。
|
||||
*
|
||||
* 这类过程反馈不进对话区(见 `ProjectChatComponentProps.onRunNotice`),
|
||||
* `id` 每次自增,保证同一句文案连续触发时也会重新弹一次。
|
||||
*/
|
||||
const [runNotice, setRunNotice] = useState<RunNotice | null>(null);
|
||||
const [notice, setNotice] = useState<LauncherNotice | null>(null);
|
||||
const noticeIdRef = useRef(0);
|
||||
const showTransientNotice = useCallback<ShowTransientNotice>(
|
||||
(message, tone) => {
|
||||
noticeIdRef.current += 1;
|
||||
setNotice({
|
||||
id: noticeIdRef.current,
|
||||
message,
|
||||
...(tone ? { tone } : {}),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
/**
|
||||
* 状态栏退役后的粘性浮层:原来写在首页输入框下方 / 项目组页面头部那一行的状态,
|
||||
* 现在原样走浮层——语义不变(`status` 被替换或清空时才换掉 / 消失),只是不再占页面。
|
||||
*
|
||||
* 只在这两个页面显示:原来那一行也只长在它们上面。进了项目工作台、模板库就不再飘在界面上,
|
||||
* 状态本身留着,回到首页或项目组时照旧显示。
|
||||
*/
|
||||
const statusNotice: LauncherNotice | null = (() => {
|
||||
if (launcherView !== 'home' && launcherView !== 'projects') {
|
||||
return null;
|
||||
}
|
||||
const message =
|
||||
launcherView === 'projects' && recentProjects.recentWorkspaceRefreshing
|
||||
? '正在检查项目状态'
|
||||
: status;
|
||||
return message ? { id: 0, message, sticky: true } : null;
|
||||
})();
|
||||
// 「做成游戏」切换记录按项目上下文(路径 + createdAt)定位。运行模式必须随
|
||||
// currentProjectContext 同步派生,不能靠 effect 后置修正:首帧挂错 lane 会先
|
||||
// 以游戏运行时挂载并消耗首轮 claim,重挂后的策划实例再也发不出首轮。
|
||||
@@ -96,6 +131,7 @@ export function WorkspaceLauncherShell({
|
||||
} | null>(null);
|
||||
const homeProject = useHomeProjectCreation({
|
||||
setStatus,
|
||||
showTransientNotice,
|
||||
setLauncherView,
|
||||
rememberRecentWorkspace,
|
||||
});
|
||||
@@ -623,9 +659,12 @@ export function WorkspaceLauncherShell({
|
||||
setLauncherView(view);
|
||||
}
|
||||
|
||||
const handleRunNotice = useCallback((notice: ProjectRunNotice) => {
|
||||
setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice }));
|
||||
}, []);
|
||||
const handleRunNotice = useCallback(
|
||||
(notice: ProjectRunNotice) => {
|
||||
showTransientNotice(notice.message, notice.tone);
|
||||
},
|
||||
[showTransientNotice],
|
||||
);
|
||||
|
||||
function showLauncherNotice(title: string) {
|
||||
setLauncherNotice({
|
||||
@@ -727,7 +766,6 @@ export function WorkspaceLauncherShell({
|
||||
{launcherView === 'home' ? (
|
||||
<HomeView
|
||||
hasPromo={launcherNotifications.length > 0}
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
recentProjectRows={recentProjectRows}
|
||||
onCreateDraftAutomatically={createHomeDraftAutomatically}
|
||||
@@ -750,7 +788,6 @@ export function WorkspaceLauncherShell({
|
||||
/>
|
||||
) : launcherView === 'projects' ? (
|
||||
<ProjectsPage
|
||||
status={status}
|
||||
homeProject={homeProject}
|
||||
recentProjects={recentProjects}
|
||||
/>
|
||||
@@ -934,7 +971,10 @@ export function WorkspaceLauncherShell({
|
||||
) : isWindowChrome ? null : (
|
||||
<AccountWalletBar controller={accountWallet} />
|
||||
)}
|
||||
<RunNoticeToast notice={runNotice} onDismiss={() => setRunNotice(null)} />
|
||||
<LauncherNoticeToast
|
||||
notice={notice ?? statusNotice}
|
||||
onDismiss={() => setNotice(null)}
|
||||
/>
|
||||
{launcherNotice ? (
|
||||
<div
|
||||
className="launcher-dialog-backdrop"
|
||||
|
||||
@@ -40,6 +40,22 @@ export type ProjectRunNotice = {
|
||||
tone?: 'success' | 'error';
|
||||
};
|
||||
|
||||
/**
|
||||
* 外壳级浮层提示的观感:成功绿、失败红,「已取消」这类无结论回显是灰的。
|
||||
*/
|
||||
export type LauncherNoticeTone = 'success' | 'neutral' | 'error';
|
||||
|
||||
/**
|
||||
* 一次性浮层提示的出口。
|
||||
*
|
||||
* 与页面上常驻状态行分工:进行中进度和失败结论留在页面上(用户要能回头看),
|
||||
* 「已取消」这类「什么都没发生」的回显只弹一次浮层,不在页面上留一行文字。
|
||||
*/
|
||||
export type ShowTransientNotice = (
|
||||
message: string,
|
||||
tone?: LauncherNoticeTone,
|
||||
) => void;
|
||||
|
||||
export type ProjectChatComponentProps = {
|
||||
initialProjectPath?: string;
|
||||
initialProjectManifest?: GameCreationAppManifest;
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
ensureHomeWebCreationEnvironment,
|
||||
HOME_WEB_PREFLIGHT_FAILURE,
|
||||
} from './homeWebPreflight';
|
||||
import type { ShowTransientNotice } from './model';
|
||||
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
|
||||
|
||||
/** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */
|
||||
@@ -72,6 +73,13 @@ function homeDraftPromptText() {
|
||||
|
||||
type UseHomeProjectCreationOptions = {
|
||||
setStatus: Dispatch<SetStateAction<string>>;
|
||||
/**
|
||||
* 取消类回显的出口(原生选择器取消、非空文件夹确认弹窗取消)。
|
||||
*
|
||||
* 取消是「什么都没发生」:只弹一次浮层,不往页面上写一行常驻状态——那一行留给进行中
|
||||
* 进度和失败结论。
|
||||
*/
|
||||
showTransientNotice: ShowTransientNotice;
|
||||
setLauncherView: Dispatch<SetStateAction<LauncherView>>;
|
||||
rememberRecentWorkspace: (projectPath: string) => void;
|
||||
};
|
||||
@@ -172,6 +180,7 @@ function resolveRecoverableHomeProjectPath(
|
||||
|
||||
export function useHomeProjectCreation({
|
||||
setStatus,
|
||||
showTransientNotice,
|
||||
setLauncherView,
|
||||
rememberRecentWorkspace,
|
||||
}: UseHomeProjectCreationOptions) {
|
||||
@@ -769,7 +778,9 @@ export function useHomeProjectCreation({
|
||||
|
||||
function cancelCreateInNonEmptyFolder() {
|
||||
setPendingNonEmptyProject(null);
|
||||
setStatus('已取消');
|
||||
// 取消不是结论:页面上那行状态留给进行中进度和失败结论,这里只弹一次浮层。
|
||||
setStatus('');
|
||||
showTransientNotice('已取消', 'neutral');
|
||||
}
|
||||
|
||||
useEscapeToClose(
|
||||
@@ -1001,7 +1012,8 @@ export function useHomeProjectCreation({
|
||||
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
|
||||
);
|
||||
if (!selectedPath) {
|
||||
setStatus('已取消');
|
||||
setStatus('');
|
||||
showTransientNotice('已取消', 'neutral');
|
||||
return;
|
||||
}
|
||||
setProjectPath(selectedPath);
|
||||
@@ -1037,7 +1049,8 @@ export function useHomeProjectCreation({
|
||||
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
|
||||
);
|
||||
if (!selectedPath) {
|
||||
setStatus('已取消');
|
||||
setStatus('');
|
||||
showTransientNotice('已取消', 'neutral');
|
||||
return;
|
||||
}
|
||||
setProjectPath(selectedPath);
|
||||
|
||||
@@ -1501,10 +1501,6 @@ textarea {
|
||||
color: var(--platform-button-primary-text);
|
||||
}
|
||||
|
||||
.launcher-project-page-status {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.launcher-project-list-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -99,7 +99,10 @@ function projectTypeLabel(projectKind: HomeProjectRow['projectKind']) {
|
||||
|
||||
type HomeViewProps = {
|
||||
hasPromo: boolean;
|
||||
status?: string;
|
||||
/**
|
||||
* 首页只发状态、不渲染状态:那一行已退役,状态统一由外壳的浮层承载
|
||||
* (见 `LauncherNoticeToast`),避免同一句话既占页面又飘在浮层里。
|
||||
*/
|
||||
onStatusChange: (status: string) => void;
|
||||
recentProjectRows: readonly HomeProjectRow[];
|
||||
onCreateDraftAutomatically: (
|
||||
@@ -124,7 +127,6 @@ type HomeViewProps = {
|
||||
|
||||
export default function HomeView({
|
||||
hasPromo,
|
||||
status = '',
|
||||
onStatusChange,
|
||||
recentProjectRows,
|
||||
onCreateDraftAutomatically,
|
||||
@@ -321,14 +323,6 @@ export default function HomeView({
|
||||
</div>
|
||||
</RichInputArea>
|
||||
</form>
|
||||
{status ? (
|
||||
<p
|
||||
className="m-0 w-[min(814px,calc(100vw-122px))] text-sm text-(--platform-warm-text) max-[760px]:w-[min(100%,calc(100vw-76px))]"
|
||||
role="status"
|
||||
>
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
{recoverableCreatedProjectPath && !creationBusy ? (
|
||||
<p
|
||||
className="m-0 flex w-[min(814px,calc(100vw-122px))] flex-wrap items-center gap-2 text-sm text-(--platform-warm-text) max-[760px]:w-[min(100%,calc(100vw-76px))]"
|
||||
|
||||
@@ -27,6 +27,15 @@ import {
|
||||
within,
|
||||
} from './harness';
|
||||
|
||||
/**
|
||||
* 外壳级浮层提示的挂点。
|
||||
*
|
||||
* 「已取消」这类取消回显只走这枚 toast,不再写成首页 / 项目组页面上的常驻状态行。
|
||||
*/
|
||||
function launcherNoticeToast() {
|
||||
return document.querySelector('[data-launcher-notice-toast="true"]');
|
||||
}
|
||||
|
||||
function homeDesignContinueView({
|
||||
prompt,
|
||||
pendingClarification = null,
|
||||
@@ -1413,7 +1422,9 @@ export function registerHomeProjectCreationTests() {
|
||||
finishDirectoryPick?.(null);
|
||||
await directoryPick;
|
||||
});
|
||||
expect(await screen.findByText('已取消')).not.toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(launcherNoticeToast()?.textContent ?? '').toContain('已取消'),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a project before handling every home message inside the project Codex conversation', async () => {
|
||||
@@ -3248,7 +3259,9 @@ export function registerRecentProjectsTests() {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开项目' }));
|
||||
|
||||
expect(await screen.findByText('已取消')).not.toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(launcherNoticeToast()?.textContent ?? '').toContain('已取消'),
|
||||
);
|
||||
expect(screen.queryByLabelText('项目目录')).toBeNull();
|
||||
expect(screen.getByText('暂无项目')).not.toBeNull();
|
||||
});
|
||||
@@ -3323,7 +3336,9 @@ export function registerRecentProjectsTests() {
|
||||
expect(screen.getByText('/tmp/non-empty-game')).not.toBeNull();
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
|
||||
expect(await screen.findByText('已取消')).not.toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(launcherNoticeToast()?.textContent ?? '').toContain('已取消'),
|
||||
);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog', { name: '文件夹不是空的' })).toBeNull();
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
|
||||
@@ -18,6 +18,7 @@ function setup(invoke: ReturnType<typeof vi.fn>) {
|
||||
return renderHook(() =>
|
||||
useHomeProjectCreation({
|
||||
setStatus: vi.fn(),
|
||||
showTransientNotice: vi.fn(),
|
||||
setLauncherView: vi.fn(),
|
||||
setAgentChatProjectPath: vi.fn(),
|
||||
rememberRecentWorkspace: vi.fn(),
|
||||
|
||||
@@ -32,6 +32,7 @@ function mount(preflight: () => Promise<unknown>) {
|
||||
const hook = renderHook(() =>
|
||||
useHomeProjectCreation({
|
||||
setStatus: vi.fn(),
|
||||
showTransientNotice: vi.fn(),
|
||||
setLauncherView: vi.fn(),
|
||||
setAgentChatProjectPath: vi.fn(),
|
||||
rememberRecentWorkspace: vi.fn(),
|
||||
|
||||
+79
-6
@@ -1,14 +1,21 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
/**
|
||||
* 运行提示的外壳级接线:`ProjectChat` 发一条提示,工作台壳真的把它渲染成浮层。
|
||||
* 浮层提示的外壳级接线:`ProjectChat` 发一条运行提示,工作台壳真的把它渲染成浮层;
|
||||
* 原来那一行状态栏(进行中进度、失败结论、取消回显)也走同一条通道,页面上不留那一行。
|
||||
*
|
||||
* 单测(`runNoticeToast`)只证明组件本身,`previewActivation` 只证明 App 会调这条通道;
|
||||
* 单测(`launcherNoticeToast`)只证明组件本身,`previewActivation` 只证明 App 会调这条通道;
|
||||
* 从「通道被调用」到「用户看到 toast」之间还差一层壳的接线(prop 名、handler、portal 挂点、
|
||||
* tone 传递)。这里用替身聊天把这一层单独钉住:以后有人把 `<RunNoticeToast/>` 挪进条件
|
||||
* tone 传递)。这里用替身聊天把这一层单独钉住:以后有人把 `<LauncherNoticeToast/>` 挪进条件
|
||||
* 分支、或在传 prop 时写错名字,这条会红。
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
@@ -22,6 +29,10 @@ import {
|
||||
|
||||
const PROJECT_PATH = '/tmp/run-notice-shell-project';
|
||||
|
||||
function StubProjectChat() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function StubRunNoticeChat({ onRunNotice }: ProjectChatComponentProps) {
|
||||
return (
|
||||
<div>
|
||||
@@ -120,10 +131,10 @@ function renderWorkbench() {
|
||||
}
|
||||
|
||||
function toastElement() {
|
||||
return document.querySelector('[data-project-run-notice-toast="true"]');
|
||||
return document.querySelector('[data-launcher-notice-toast="true"]');
|
||||
}
|
||||
|
||||
describe('运行提示的外壳接线', () => {
|
||||
describe('浮层提示的外壳接线', () => {
|
||||
it('成功提示渲染成浮层,且不落在对话容器里', async () => {
|
||||
renderWorkbench();
|
||||
await screen.findByLabelText('项目开发工作台');
|
||||
@@ -155,4 +166,66 @@ describe('运行提示的外壳接线', () => {
|
||||
'运行游戏失败:预览端口被占用',
|
||||
);
|
||||
});
|
||||
|
||||
it('状态栏退役:进行中状态与取消回显都只走浮层,首页不留那一行', async () => {
|
||||
let finishDirectoryPick: ((path: string | null) => void) | null = null;
|
||||
const directoryPick = new Promise<string | null>((resolve) => {
|
||||
finishDirectoryPick = resolve;
|
||||
});
|
||||
const runtimeHarness = createProjectChatRuntimeHarness({});
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'pick_local_project_directory') {
|
||||
return directoryPick;
|
||||
}
|
||||
return runtimeHarness.invoke(command);
|
||||
});
|
||||
window.__TAURI__ = {
|
||||
core: { invoke: invoke as never },
|
||||
event: { listen: runtimeHarness.listen as never },
|
||||
};
|
||||
render(
|
||||
<WorkspaceLauncherShell
|
||||
currentUser={testAuthUser}
|
||||
initialView="home"
|
||||
onLogout={vi.fn()}
|
||||
ProjectChat={StubProjectChat}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '打开项目' }));
|
||||
|
||||
// 进行中状态:粘性浮层,页面上(以前那一行的位置)不留文字。
|
||||
await waitFor(() =>
|
||||
expect(toastElement()?.textContent ?? '').toContain('正在选择项目'),
|
||||
);
|
||||
expect(screen.getByLabelText('首页输入').textContent ?? '').not.toContain(
|
||||
'正在选择项目',
|
||||
);
|
||||
|
||||
// 取消:粘性状态被一次性回显替换掉,同样只在浮层里。
|
||||
await act(async () => {
|
||||
finishDirectoryPick?.(null);
|
||||
await directoryPick;
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(toastElement()?.textContent ?? '').toContain('已取消'),
|
||||
);
|
||||
expect(screen.getByLabelText('首页输入').textContent ?? '').not.toContain(
|
||||
'已取消',
|
||||
);
|
||||
});
|
||||
|
||||
it('粘性状态只长在首页 / 项目组,进项目工作台不再飘在界面上', async () => {
|
||||
renderWorkbench();
|
||||
await screen.findByLabelText('项目开发工作台');
|
||||
|
||||
// 「打开项目」把状态写成「已打开项目」后立刻切到工作台:那一行原来只长在列表页上,
|
||||
// 换成浮层也不能飘到工作台上来。
|
||||
expect(toastElement()).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '首页' }));
|
||||
await waitFor(() =>
|
||||
expect(toastElement()?.textContent ?? '').toContain('已打开项目'),
|
||||
);
|
||||
});
|
||||
});
|
||||
+67
-9
@@ -2,22 +2,22 @@
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RunNoticeToast } from '../src/features/app-shell/RunNoticeToast';
|
||||
import { LauncherNoticeToast } from '../src/features/app-shell/LauncherNoticeToast';
|
||||
|
||||
const RUN_MESSAGE = '运行通过,已载入客户端运行视图';
|
||||
|
||||
function toastElement() {
|
||||
return document.querySelector('[data-project-run-notice-toast="true"]');
|
||||
return document.querySelector('[data-launcher-notice-toast="true"]');
|
||||
}
|
||||
|
||||
describe('运行 / 预览浮层提示', () => {
|
||||
describe('外壳浮层提示', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('没有提示时不渲染任何浮层', () => {
|
||||
render(<RunNoticeToast notice={null} onDismiss={vi.fn()} />);
|
||||
render(<LauncherNoticeToast notice={null} onDismiss={vi.fn()} />);
|
||||
|
||||
expect(toastElement()).toBeNull();
|
||||
expect(screen.queryByText(RUN_MESSAGE)).toBeNull();
|
||||
@@ -28,7 +28,7 @@ describe('运行 / 预览浮层提示', () => {
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(
|
||||
<RunNoticeToast
|
||||
<LauncherNoticeToast
|
||||
notice={{ id: 1, message: RUN_MESSAGE }}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
@@ -48,14 +48,14 @@ describe('运行 / 预览浮层提示', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
const { rerender } = render(
|
||||
<RunNoticeToast
|
||||
<LauncherNoticeToast
|
||||
notice={{ id: 1, message: RUN_MESSAGE }}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<RunNoticeToast
|
||||
<LauncherNoticeToast
|
||||
notice={{ id: 2, message: RUN_MESSAGE }}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
@@ -71,9 +71,30 @@ describe('运行 / 预览浮层提示', () => {
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('父组件重渲染(换一个内联收起回调)不重新计时', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
// 同一条提示的状态对象在外壳里是稳定的;会变的只有每次都新写的内联回调。
|
||||
const notice = { id: 1, message: RUN_MESSAGE };
|
||||
const { rerender } = render(
|
||||
<LauncherNoticeToast notice={notice} onDismiss={onDismiss} />,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_000);
|
||||
});
|
||||
rerender(
|
||||
<LauncherNoticeToast notice={notice} onDismiss={() => onDismiss()} />,
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('失败提示用错误色调,不需要调用方再拼一套文案', () => {
|
||||
render(
|
||||
<RunNoticeToast
|
||||
<LauncherNoticeToast
|
||||
notice={{
|
||||
id: 1,
|
||||
tone: 'error',
|
||||
@@ -97,7 +118,7 @@ describe('运行 / 预览浮层提示', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<RunNoticeToast
|
||||
<LauncherNoticeToast
|
||||
notice={{
|
||||
id: 1,
|
||||
tone: 'error',
|
||||
@@ -117,4 +138,41 @@ describe('运行 / 预览浮层提示', () => {
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('取消回显走同样的浮层,短时收起', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<LauncherNoticeToast
|
||||
notice={{ id: 1, tone: 'neutral', message: '已取消' }}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('已取消')).not.toBeNull();
|
||||
expect(toastElement()?.querySelector('[role="status"]')).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_600);
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('粘性提示(承接原状态栏)不自动收起', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<LauncherNoticeToast
|
||||
notice={{ id: 1, sticky: true, message: '正在创建工作区' }}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('正在创建工作区')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,17 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-09-29 外壳状态栏退役:`status` 一行改由浮层承载
|
||||
|
||||
- 背景:首页输入框下方、项目组页面头部那一行由 `WorkspaceLauncher` 的 `status` 承接,写入的内容很杂——进行中进度(`正在创建工作区`、`正在选择项目`)、“已取消 / 已创建项目 / 已打开项目目录”这类回显、失败结论(`创建未完成,请重试`、工作区看门狗文案、Provider 报错)以及项目组页面的 `正在检查项目状态`。这一行常驻占页面,用户明确要求整行改成浮层提示,而不是只把「已取消」挑出来。
|
||||
- 决策(一个通道):`status` 不再由页面渲染,统一由外壳浮层承载(`LauncherNoticeToast`)。`HomeView` 只保留 `onStatusChange`,`ProjectsPage` 去掉 `status` prop 与 `.launcher-project-page-status` 那段;项目组页面的 `正在检查项目状态` 由外壳按 `recentWorkspaceRefreshing` 与当前视图合成后进入同一通道。
|
||||
- 决策(生存期按需要分两种):进行中进度与失败结论走**粘性**浮层(`sticky: true`,不自动收起)——这与原来那一行一致,切页回来仍能看到,重建的 10 分钟看门狗文案也不会一闪而过;成功、取消这类回显走**一次性**浮层(`showTransientNotice`,`tone='neutral'` / 成功 2.6 秒、失败 6 秒)。一次性提示优先显示,自己收起后粘性状态(仍在的话)继续显示。
|
||||
- 决策(显示范围照旧):粘性状态只挂在 `home` / `projects` 两个页面上,进项目工作台、模板库、帮助页不再飘在界面上(原来那一行也只长在列表页上),状态本身留着,回首页 / 项目组时照旧显示;运行 / 预览反馈与取消回显不受这条限制,仍在哪里发生就在哪里弹。
|
||||
- 决策(一枚 toast):`RunNoticeToast` 更名 `LauncherNoticeToast`(类型 `LauncherNotice` 增加 `sticky`、挂点 `data-launcher-notice-toast`),运行 / 预览反馈、状态栏内容、取消回显共用一个挂点与一套色调;长文案容器限宽 `min(680px, 100vw - 2rem)` 并换行。
|
||||
- 决策(计时不被重渲染打断):收起回调只从 ref 读,不写进 effect 依赖——外壳每渲染一次都会换一个内联箭头函数,写进依赖会让 2.6 秒在每次重渲染时重新起算。
|
||||
- 影响范围:`apps/ai-game-creator-shell/src/features/app-shell/{LauncherNoticeToast.tsx,model.ts,WorkspaceLauncher.tsx,ProjectCreation.tsx,useHomeProjectCreation.ts}`、`src/view/home/index.tsx`、`src/styles.css`;用例 `apps/ai-game-creator-shell/tests/launcherNoticeToast.test.tsx`(新增粘性不收起、重渲染不重新计时、取消短时收起)与 `launcherNoticeShellWiring.test.tsx`(原 `runNoticeToast` / `runNoticeShellWiring`;新增「进行中状态与取消回显都只走浮层、首页不留那一行」)、`tests/appSurface/home.suite.ts` 三处取消断言、`tests/{homeWebPreflight,designProjectRestore}.test.tsx` 的 hook 参数。
|
||||
- 验证:`npx vitest run apps/ai-game-creator-shell/tests` → 191 passed / 1 skipped;`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`;`eslint`(改动文件)、`prettier --check`(改动代码文件)、`npm run check:encoding`、`npm run check:doc-index`、`npm run typecheck`、`git diff --check`。
|
||||
- 边界:本次只改这一条状态通道的呈现。首页那行**带动作**的提示留在原地——建项已落盘但没进项目时的「已创建的工作区:<路径>」+「打开已创建的工作区」按钮要能点,不能飘走;设置弹层里「项目创建目录」选择器的「已取消」仍是弹层内自己的状态文案,未一并改动;浮层外观没有在真机窗口里目测过(取消原生选择器需要 Tauri 客户端窗口)。
|
||||
|
||||
## 2026-09-29 Game Agent 读取工具接受绝对路径和项目外路径
|
||||
|
||||
- 决策:`agc_read_project_context` 与 `agc_list_project_files` 可以读取绝对路径,以及用 `..` 离开当前项目的路径。项目内相对路径仍拒绝 `.agent`、凭据文件名、符号链接和硬链接。项目外读取同样拒绝这些受保护名字和链接,但不因路径落在项目外而失败。
|
||||
@@ -9553,7 +9565,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 决策(运行页顶栏是唯一入口):运行区域上方的状态行与预览地址小字整体退役(运行画面回到两行栅格);预览地址改成顶栏动作区里的一枚「在浏览器打开」按钮(opener 插件的 `openUrl`,只在有活预览时渲染;`scripts/check-native-shells.mjs` 另加「视图里 `openUrl(` 只有一个调用点」的判据);版本入口搬进同一个顶栏动作区,外观复用 `.game-workbench-view-actions button` 的基础规则,不再自带边框 / 底色 / hover,也不再是绝对定位浮层。版本入口在**资源画布与运行页**显示,UI 编辑器壳里不渲染(那一页是聚焦编辑某个资源的界面)。版本名改用一层 span 承载省略号——按钮是 flex 容器,文本直接挂在按钮上时 `text-overflow` 不生效。
|
||||
- 决策(运行页不挂生成任务):`ResourceCanvasAssetGenerationTasksPanelView` 只在资源画布 / UI 编辑器出现,运行页的入口、面板与锚点都不渲染;`[data-generation-tasks-placement='run']` 那一档坐标与组件 `placement` 联合类型里的 `run` 一并删除。任务不丢,切回资源页即可见。
|
||||
- 边界:预览的**启动与切换**仍然只走内置运行画面、不自动开系统浏览器——`scripts/check-native-shells.mjs` 那条负向守卫保持原样,本轮只补「浏览器入口只有顶栏这一枚按钮」的正向断言。
|
||||
- 影响面:`apps/ai-game-creator-shell/src/{App.tsx,styles.css,view/project-development/index.tsx,features/app-shell/*,features/resource-canvas/*}`;用例新增 `tests/runPreviewBrowserOpen.test.tsx`、`tests/runNoticeToast.test.tsx`、`tests/runNoticeShellWiring.test.tsx`(外壳级:替身聊天发提示 → 壳真的渲染浮层)、`tests/gameRunToolbarActionsStyle.test.ts`,改写 `tests/previewActivation.test.tsx`(原断言「聊天里出现已载入运行视图」的地方改为断言 toast 通道 + 对话容器里没有这类提示,并补一条「启动失败走失败色提示且不写对话区」)与 `tests/appSurface/project-development.suite.ts` 的生成任务入口用例。
|
||||
- 影响面:`apps/ai-game-creator-shell/src/{App.tsx,styles.css,view/project-development/index.tsx,features/app-shell/*,features/resource-canvas/*}`;用例新增 `tests/runPreviewBrowserOpen.test.tsx`、`tests/runNoticeToast.test.tsx`、`tests/runNoticeShellWiring.test.tsx`(外壳级:替身聊天发提示 → 壳真的渲染浮层)、`tests/gameRunToolbarActionsStyle.test.ts`,改写 `tests/previewActivation.test.tsx`(原断言「聊天里出现已载入运行视图」的地方改为断言 toast 通道 + 对话容器里没有这类提示,并补一条「启动失败走失败色提示且不写对话区」)与 `tests/appSurface/project-development.suite.ts` 的生成任务入口用例。(后续更名:`RunNoticeToast` 与那两条用例自 2026-09-29 起叫 `LauncherNoticeToast` / `launcherNoticeToast.test.tsx` / `launcherNoticeShellWiring.test.tsx`,见当日条目。)
|
||||
- 验证:`appSurface` 全量、AGC 壳目录全量、`npm run typecheck`、`check:native-shells:contract`、`check:encoding`、`git diff --check` 全绿;真机观感未复验。
|
||||
|
||||
## 2026-09-22 DirectProject 聊天状态显式化:回合三态 + 「在跑吗」唯一派生入口 + 数据流地图
|
||||
|
||||
@@ -1140,6 +1140,7 @@ game-project/
|
||||
- 结构化对话记录按授权本地项目路径追加 JSONL;正式聊天读取 Supervisor active Session 与只读 legacy 项目历史,开发单 Agent 对话读取对应 Agent Session。开发入口已支持本地 Session 新建、切换、归档和分叉,但不提供云端同步。
|
||||
- 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。
|
||||
- 首页发送、项目组目录选择和本地文件选择必须使用 Tauri 非阻塞原生 picker,并把选择器绑定到当前 `client` 窗口;禁止在同步 command 中调用 `blocking_pick_folder` / `blocking_pick_file` 阻塞 WebView 事件循环。选择器打开期间保留首页草稿可编辑,取消后恢复“开启创作”按钮并回显“已取消”。
|
||||
- 外壳状态栏(首页输入框下方、项目组页面头部那一行)不占页面:`status` 一律由外壳的浮层承载(见 `LauncherNoticeToast`)。进行中进度与失败结论走粘性浮层,生存期与原来那行一致(被下一条状态替换或清空时才消失,切页回来仍可见);成功、取消这类回显走一次性浮层,成功与取消 2.6 秒、失败 6 秒收起。
|
||||
- GameAgent 首页“灵感推荐”当前使用客户端随包发布的内置图片素材,不请求主站 `/creation` 的陶泥儿精选 `/api/editor/showcase/resources`,也不引入远端灵感数据源。图片按原始宽高比组成响应式瀑布流,首页内容区域允许纵向滚动;点击图片只打开全屏原图预览,点击图片外遮罩或按 Escape 关闭,不展示标题或其它业务动作。后续改接远端数据前仍须先明确独立数据契约和交互验收。
|
||||
- 首页正式桌面版以约 `814px` 的居中内容栏组织品牌区、创作类型、输入框、最近项目与灵感推荐;品牌区和类型按钮在内容栏左缘对齐,输入框和下方模块仍保持整体居中。标题使用深色暖橙层级,固定副标题为“你的游戏创作管家”;选中的创作类型使用实心暖橙按钮,未选中项使用浅色描边,输入框使用更舒展的单行创作起始比例。
|
||||
- 最近项目固定展示至多三个横向信息卡:左侧为本地渐变封面占位,右侧只投影项目名称、项目类型、目录真实修改时间和已有最近运行状态。目录检查通过 `modifiedAt` 提供修改时间;没有封面资产时不得假装读取了项目封面,也不新增封面持久化字段。
|
||||
|
||||
Reference in New Issue
Block a user