评审后续:修正标题栏项目标签的空态、视图门控与文案
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m7s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m38s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m42s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Backend tests (pull_request) Successful in 8m12s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m54s
Project CI / Repository checks (pull_request) Successful in 6m58s
Project CI / Native shell tests (pull_request) Successful in 9m32s

- 面板形态:快照读取成功但没有在跑回合时不再渲染「未能读取正在运行的项目」,补回 !readFailed 守卫
- 发布门控:currentProjectPath / currentProjectName 只在 launcherView === 'project-development' 时发布,离开工作台按口径回落到最近启动的在跑项目
- 菜单头部只报运行中数量,读取失败结论由 role=status 一行独占,避免同一句连排两遍
- 标签提示拆成「作用域 + 运行状态」:回落态记为「最近运行的项目:X」、状态位「共 N 个项目在运行」,不再重复「正在运行」
- 数量徽标补 title 说明数字含义
- 用例:新增面板空态回归与两条视图门控回归,更新两处标签断言;三条定向文件 25 passed
- 文档:决策日志补 2026-10-05 评审后续条目并修正实施计划交叉引用,里程碑补 2026-10-05 修订,实施计划与主规范同步口径
This commit is contained in:
2026-10-05 15:35:55 +08:00
parent f2f33aead3
commit 45ae29461c
10 changed files with 202 additions and 26 deletions
@@ -20,7 +20,8 @@ export type WindowChromeActiveProjectRuns = {
currentProjectPath?: string | null;
/**
* 当前工作台打开的项目名(清单项目名)。标题栏标签的主文案认这一个:正在运行的项目只在
* 徽标和展开菜单里表达,否则「我在哪个项目」会被后台别的项目顶掉。
* 徽标和展开菜单里表达,否则「我在哪个项目」会被后台别的项目顶掉。只在项目视图发布:
* 离开工作台后置空,标签回落到最近启动的在跑项目。
*/
currentProjectName?: string | null;
readFailed?: boolean;
@@ -120,14 +120,18 @@ export function ActiveProjectRunsPanel({
</span>
);
}
const runningLabel = readFailed
/**
* 标签提示 = 作用域 + 运行状态,分成两段是为了不在一句话里重复「正在运行」:
* 回落态的主文案本身就是「最近启动的在跑项目」,状态位再写一遍会读成「正在运行的项目:X;
* 正在运行 N 个项目」。
*/
const scopeLabel = currentName
? `当前项目:${primaryName}`
: `最近运行的项目:${primaryName}`;
const runStateLabel = readFailed
? '正在运行的项目读取失败'
: `正在运行 ${runningCount} 个项目`;
const triggerLabel = `${
currentName
? `当前项目:${primaryName}`
: `正在运行的项目:${primaryName}`
};${runningLabel}`;
: `共 ${runningCount} 个项目在运行`;
const triggerLabel = `${scopeLabel};${runStateLabel}`;
const openProject = (projectPath: string) => {
setOpen(false);
onOpenProject?.(projectPath);
@@ -155,7 +159,12 @@ export function ActiveProjectRunsPanel({
{primaryName}
</span>
{runningCount > 0 ? (
<span className="launcher-runs-titlebar-count">{runningCount}</span>
<span
className="launcher-runs-titlebar-count"
title={`${runningCount} 个项目正在运行`}
>
{runningCount}
</span>
) : null}
<ChevronDown
className={`launcher-runs-titlebar-chevron${open ? ' is-open' : ''}`}
@@ -167,7 +176,8 @@ export function ActiveProjectRunsPanel({
<div className="launcher-runs-titlebar-menu" role="menu">
<div className="launcher-runs-titlebar-menu-header">
<strong>正在运行的项目</strong>
<span>{runningLabel}</span>
{/* 头部只报数量:读取失败的结论由下面那行 role=status 独占,避免同一句连排两遍。 */}
<span>{runningCount > 0 ? `${runningCount} 个` : null}</span>
</div>
{readFailed ? (
<p className="launcher-runs-titlebar-menu-note" role="status">
@@ -229,6 +239,10 @@ export function ActiveProjectRunsPanel({
}
if (runningCount === 0) {
// 快照读到了、只是没有在跑回合:整块不渲染,不能报成读取失败。
if (!readFailed) {
return null;
}
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
return (
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
@@ -300,10 +300,22 @@ export function WorkspaceLauncherShell({
openActiveProjectRef.current = openActiveProject;
useEffect(() => {
/**
* 只在项目视图发布"当前项目"身份。
*
* `currentProjectContext` 是会话级状态:离开工作台(首页 / 项目组 / 模板库)不会被清空,
* 而活动回合快照是全局轮询。若照搬上下文,回首页后标签会一直是「当前项目:<上次打开的项目>」,
* 徽标数字却属于别的项目;和同文件里按 `launcherView` 门控的窗口标题 effect 也对不上。
*/
const inProjectView = launcherView === 'project-development';
setActiveProjectRuns({
activeTurns,
currentProjectPath: currentProjectContext?.projectPath ?? null,
currentProjectName: currentProjectContext?.projectName ?? null,
currentProjectPath: inProjectView
? (currentProjectContext?.projectPath ?? null)
: null,
currentProjectName: inProjectView
? (currentProjectContext?.projectName ?? null)
: null,
readFailed: snapshotReadFailed,
onOpenProject: (projectPath: string) =>
openActiveProjectRef.current(projectPath),
@@ -312,6 +324,7 @@ export function WorkspaceLauncherShell({
activeTurns,
currentProjectContext?.projectName,
currentProjectContext?.projectPath,
launcherView,
setActiveProjectRuns,
snapshotReadFailed,
]);
@@ -164,11 +164,11 @@ describe('WindowChrome', () => {
await user.click(screen.getByRole('button', { name: '显示运行项目' }));
expect(
screen.getByRole('button', { name: /正在运行的项目:后开始/ }),
screen.getByRole('button', { name: /最近运行的项目:后开始/ }),
).toBeTruthy();
expect(screen.queryByRole('menu')).toBeNull();
await user.click(
screen.getByRole('button', { name: /正在运行的项目:后开始/ }),
screen.getByRole('button', { name: /最近运行的项目:后开始/ }),
);
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
});
@@ -205,7 +205,7 @@ describe('WindowChrome', () => {
expect(screen.queryByText('别的项目')).toBeNull();
expect(
screen.getByRole('button', {
name: '当前项目:飞鸟 v2;正在运行 1 个项目',
name: '当前项目:飞鸟 v2;共 1 个项目在运行',
}),
).toBeTruthy();
});
@@ -308,6 +308,16 @@ describe('ActiveProjectRunsPanel', () => {
);
});
/**
* 回归:快照读到了、只是没有在跑回合时,面板形态曾把这个健康空态渲染成
* 「未能读取正在运行的项目」,把"没在跑"说成"没读到"。
*/
it('面板形态下快照读取正常但没有在跑回合时整块不渲染', () => {
const { container } = render(<ActiveProjectRunsPanel activeTurns={[]} />);
expect(container.firstChild).toBeNull();
});
it('不在项目内时标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
const onOpenProject = vi.fn();
render(
@@ -372,7 +382,7 @@ describe('ActiveProjectRunsPanel', () => {
);
const trigger = screen.getByRole('button', {
name: '当前项目:飞鸟 v2;正在运行 1 个项目',
name: '当前项目:飞鸟 v2;共 1 个项目在运行',
});
expect(trigger.textContent).toContain('飞鸟 v2');
expect(trigger.textContent).toContain('1');
@@ -1,8 +1,9 @@
/** @vitest-environment jsdom */
import type { ReactNode } from 'react';
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { vi } from 'vitest';
import type { LauncherProjectContext } from '../src/app/types';
import { WindowChrome } from '../src/components/WindowChrome';
import {
useWindowChrome,
@@ -12,6 +13,7 @@ import {
import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
import {
act,
createGameCreationAppManifest,
expect,
it,
React,
@@ -24,6 +26,7 @@ const homeProjectOverride = vi.hoisted(() => ({
openProject: null as
| null
| ((path: string, mode: 'open' | 'create') => Promise<void>),
projectContext: null as LauncherProjectContext | null,
}));
vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => {
@@ -36,9 +39,16 @@ vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => {
...args: Parameters<typeof actual.useHomeProjectCreation>
) {
const result = actual.useHomeProjectCreation(...args);
return homeProjectOverride.openProject
? { ...result, openProject: homeProjectOverride.openProject }
const withContext = homeProjectOverride.projectContext
? {
...result,
currentProjectContext: homeProjectOverride.projectContext,
setCurrentProjectContext: (() => undefined) as never,
}
: result;
return homeProjectOverride.openProject
? { ...withContext, openProject: homeProjectOverride.openProject }
: withContext;
},
};
});
@@ -112,3 +122,113 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且
rendered.unmount();
}
});
/**
* 只记录工作台发布给窗口标题栏的「当前项目」名;转发超过 12 次就不再往下传,
* 仅用于防止回归时测试进入无限 setState。
*/
function ProjectScopeBridge({
children,
record,
}: {
children: ReactNode;
record: (projectName: string | null) => void;
}) {
const chrome = useWindowChrome();
const { setActiveProjectRuns } = chrome;
const forwardCount = useRef(0);
const publish = useCallback(
(next: WindowChromeActiveProjectRuns | null) => {
if (next) record(next.currentProjectName ?? null);
if (forwardCount.current >= 12) return;
forwardCount.current += 1;
setActiveProjectRuns(next);
},
[record, setActiveProjectRuns],
);
return (
<WindowChromeContext.Provider
value={{ ...chrome, setActiveProjectRuns: publish }}
>
{children}
</WindowChromeContext.Provider>
);
}
/** 会话里已打开过项目:`currentProjectContext` 在离开工作台后不会被清空。 */
function openedProjectContext(): LauncherProjectContext {
return {
projectPath: '/tmp/window-scope-project',
projectName: '飞鸟 v2',
projectKind: 'web',
manifest: createGameCreationAppManifest('window-scope-project', '飞鸟 v2'),
projectRevision: null,
creationType: null,
startMode: null,
initialPrompt: '',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: 1,
};
}
function renderScopeRecordedAt(
initialView: 'projects' | 'project-development',
record: (projectName: string | null) => void,
) {
return render(
<WindowChrome>
<ProjectScopeBridge record={record}>
<WorkspaceLauncherShell
currentUser={testAuthUser}
onLogout={() => undefined}
initialView={initialView}
ProjectChat={() => null}
/>
</ProjectScopeBridge>
</WindowChrome>,
);
}
/**
* 回归:`currentProjectContext` 是会话级的(离开工作台不清空),发布时若照搬它,
* 首页 / 项目组 / 模板库上标签会一直写「当前项目:<上次打开的项目>」,徽标数字却属于
* 别的项目。只有工作台视图才认这个身份。
*/
it('不在工作台视图时不发布会话残留的项目身份', async () => {
const publishedNames: Array<string | null> = [];
homeProjectOverride.projectContext = openedProjectContext();
resetTauriRuntime();
const rendered = renderScopeRecordedAt('projects', (name) => {
publishedNames.push(name);
});
try {
await act(async () => {
await Promise.resolve();
});
expect(publishedNames.length).toBeGreaterThan(0);
expect(publishedNames.every((name) => name === null)).toBe(true);
} finally {
homeProjectOverride.projectContext = null;
rendered.unmount();
}
});
it('工作台视图里照常发布当前项目名', async () => {
const publishedNames: Array<string | null> = [];
homeProjectOverride.projectContext = openedProjectContext();
resetTauriRuntime();
const rendered = renderScopeRecordedAt('project-development', (name) => {
publishedNames.push(name);
});
try {
await act(async () => {
await Promise.resolve();
});
expect(publishedNames).toContain('飞鸟 v2');
} finally {
homeProjectOverride.projectContext = null;
rendered.unmount();
}
});