Merge pull request '修复:AGC 窗口标题栏项目标签改认当前项目(#618)' (#621) from fix/agc-titlebar-project-label into master
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 6m47s
Project CI / AI game creator shell Rust crates (push) Successful in 4m38s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m6s
Project CI / Frontend tests (push) Successful in 3m41s
Project CI / Backend tests (push) Successful in 8m0s
Project CI / AI game creator shell web tests (push) Successful in 2m48s
Project CI / Repository checks (push) Successful in 7m17s
Project CI / Native shell tests (push) Successful in 9m27s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 6m47s
Project CI / AI game creator shell Rust crates (push) Successful in 4m38s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m6s
Project CI / Frontend tests (push) Successful in 3m41s
Project CI / Backend tests (push) Successful in 8m0s
Project CI / AI game creator shell web tests (push) Successful in 2m48s
Project CI / Repository checks (push) Successful in 7m17s
Project CI / Native shell tests (push) Successful in 9m27s
Reviewed-on: http://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/621
This commit was merged in pull request #621.
This commit is contained in:
@@ -177,6 +177,7 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={activeProjectRuns.activeTurns}
|
||||
currentProjectPath={activeProjectRuns.currentProjectPath}
|
||||
currentProjectName={activeProjectRuns.currentProjectName}
|
||||
readFailed={activeProjectRuns.readFailed}
|
||||
onOpenProject={activeProjectRuns.onOpenProject}
|
||||
placement="titlebar"
|
||||
|
||||
@@ -18,6 +18,12 @@ export type WindowChromeContextValue = {
|
||||
export type WindowChromeActiveProjectRuns = {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
currentProjectPath?: string | null;
|
||||
/**
|
||||
* 当前工作台打开的项目名(清单项目名)。标题栏标签的主文案认这一个:正在运行的项目只在
|
||||
* 徽标和展开菜单里表达,否则「我在哪个项目」会被后台别的项目顶掉。只在项目视图发布:
|
||||
* 离开工作台后置空,标签回落到最近启动的在跑项目。
|
||||
*/
|
||||
currentProjectName?: string | null;
|
||||
readFailed?: boolean;
|
||||
onOpenProject?: (projectPath: string) => void;
|
||||
};
|
||||
|
||||
@@ -6,15 +6,18 @@ import { projectNameFromPath } from '../agent-runtime';
|
||||
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
|
||||
|
||||
/**
|
||||
* 窗口标题栏的"正在运行的项目"入口,也保留面板布局供独立组件测试和复用。
|
||||
* 窗口标题栏的项目入口,也保留面板布局供独立组件测试和复用。
|
||||
*
|
||||
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连),
|
||||
* 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时
|
||||
* 整块不渲染,不留空白占位。
|
||||
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连)。
|
||||
* 标题栏形态的主文案恒为**当前项目**:正在运行的项目只占圆点、数量徽标与展开菜单,
|
||||
* 否则「我在哪个项目」会被后台别的项目顶掉;不在项目内(没有 `currentProjectName`)时
|
||||
* 才回落到最近启动的运行项目。没有在跑回合且没读失败时整块不渲染,不留空白占位。
|
||||
*/
|
||||
export type ActiveProjectRunsPanelProps = {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
currentProjectPath?: string | null;
|
||||
/** 当前工作台打开的项目名;有值即顶栏主文案,运行中的项目不再抢它。 */
|
||||
currentProjectName?: string | null;
|
||||
readFailed?: boolean;
|
||||
onOpenProject?: (projectPath: string) => void;
|
||||
placement?: 'panel' | 'titlebar';
|
||||
@@ -58,6 +61,7 @@ function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) {
|
||||
export function ActiveProjectRunsPanel({
|
||||
activeTurns,
|
||||
currentProjectPath = null,
|
||||
currentProjectName = null,
|
||||
readFailed = false,
|
||||
onOpenProject,
|
||||
placement = 'panel',
|
||||
@@ -87,11 +91,26 @@ export function ActiveProjectRunsPanel({
|
||||
};
|
||||
}, [open, placement]);
|
||||
|
||||
if (activeTurns.length === 0) {
|
||||
if (!readFailed) {
|
||||
const now = Date.now();
|
||||
const orderedTurns = [...activeTurns].sort(
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
const latestTurn = orderedTurns[orderedTurns.length - 1] ?? null;
|
||||
const runningCount = orderedTurns.length;
|
||||
const currentName = currentProjectName?.trim() ?? '';
|
||||
/**
|
||||
* 标签主文案只认当前项目:正在运行的项目不在标题栏抢主文案,只在徽标和展开菜单里表达。
|
||||
* 不在项目内(没有当前项目名)时才回落到最近启动的运行项目。
|
||||
*/
|
||||
const primaryName =
|
||||
currentName || (latestTurn ? activeTurnDisplayName(latestTurn) : '');
|
||||
|
||||
if (placement === 'titlebar') {
|
||||
if (!latestTurn && !readFailed) {
|
||||
return null;
|
||||
}
|
||||
if (placement === 'titlebar') {
|
||||
// 读不到快照、又没有当前项目可显示:保持原来那条只读提示,不假装成可展开入口。
|
||||
if (!primaryName) {
|
||||
return (
|
||||
<span
|
||||
className="launcher-runs-titlebar launcher-runs-titlebar--error"
|
||||
@@ -101,26 +120,18 @@ export function ActiveProjectRunsPanel({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<span className="launcher-runs-panel-note" role="status">
|
||||
未能读取正在运行的项目
|
||||
</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const orderedTurns = [...activeTurns].sort(
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
if (placement === 'titlebar') {
|
||||
const latestTurn = orderedTurns[orderedTurns.length - 1];
|
||||
if (!latestTurn) {
|
||||
return null;
|
||||
}
|
||||
const latestName = activeTurnDisplayName(latestTurn);
|
||||
/**
|
||||
* 标签提示 = 作用域 + 运行状态,分成两段是为了不在一句话里重复「正在运行」:
|
||||
* 回落态的主文案本身就是「最近启动的在跑项目」,状态位再写一遍会读成「正在运行的项目:X;
|
||||
* 正在运行 N 个项目」。
|
||||
*/
|
||||
const scopeLabel = currentName
|
||||
? `当前项目:${primaryName}`
|
||||
: `最近运行的项目:${primaryName}`;
|
||||
const runStateLabel = readFailed
|
||||
? '正在运行的项目读取失败'
|
||||
: `共 ${runningCount} 个项目在运行`;
|
||||
const triggerLabel = `${scopeLabel};${runStateLabel}`;
|
||||
const openProject = (projectPath: string) => {
|
||||
setOpen(false);
|
||||
onOpenProject?.(projectPath);
|
||||
@@ -129,23 +140,30 @@ export function ActiveProjectRunsPanel({
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="launcher-runs-titlebar"
|
||||
data-active-project-count={orderedTurns.length}
|
||||
data-active-project-count={runningCount}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-runs-titlebar-trigger"
|
||||
aria-label={`正在运行的项目:${latestName}`}
|
||||
aria-label={triggerLabel}
|
||||
title={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span className="launcher-runs-panel-dot" aria-hidden="true" />
|
||||
<span className="launcher-runs-titlebar-name" title={latestName}>
|
||||
{latestName}
|
||||
<span
|
||||
className={`launcher-runs-panel-dot${readFailed ? ' is-warning' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="launcher-runs-titlebar-name" title={primaryName}>
|
||||
{primaryName}
|
||||
</span>
|
||||
{orderedTurns.length > 1 ? (
|
||||
<span className="launcher-runs-titlebar-count">
|
||||
{orderedTurns.length}
|
||||
{runningCount > 0 ? (
|
||||
<span
|
||||
className="launcher-runs-titlebar-count"
|
||||
title={`${runningCount} 个项目正在运行`}
|
||||
>
|
||||
{runningCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
@@ -158,51 +176,83 @@ export function ActiveProjectRunsPanel({
|
||||
<div className="launcher-runs-titlebar-menu" role="menu">
|
||||
<div className="launcher-runs-titlebar-menu-header">
|
||||
<strong>正在运行的项目</strong>
|
||||
<span>{orderedTurns.length} 个</span>
|
||||
{/* 头部只报数量:读取失败的结论由下面那行 role=status 独占,避免同一句连排两遍。 */}
|
||||
<span>{runningCount > 0 ? `${runningCount} 个` : null}</span>
|
||||
</div>
|
||||
<ul className="launcher-runs-titlebar-list">
|
||||
{orderedTurns.map((turn) => {
|
||||
const name = activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
const isCurrent = Boolean(
|
||||
currentProjectPath &&
|
||||
projectPathsMatchForInvalidation(
|
||||
turn.projectPath,
|
||||
currentProjectPath,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<li key={`${turn.projectPath}:${turn.turnId}`}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="launcher-runs-titlebar-item"
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
disabled={!onOpenProject}
|
||||
onClick={() => openProject(turn.projectPath)}
|
||||
>
|
||||
<span
|
||||
className="launcher-runs-titlebar-item-name"
|
||||
title={name}
|
||||
{readFailed ? (
|
||||
<p className="launcher-runs-titlebar-menu-note" role="status">
|
||||
正在运行的项目读取失败
|
||||
</p>
|
||||
) : null}
|
||||
{runningCount > 0 ? (
|
||||
<ul className="launcher-runs-titlebar-list">
|
||||
{orderedTurns.map((turn) => {
|
||||
const isCurrent = Boolean(
|
||||
currentProjectPath &&
|
||||
projectPathsMatchForInvalidation(
|
||||
turn.projectPath,
|
||||
currentProjectPath,
|
||||
),
|
||||
);
|
||||
// 当前项目这一行与标签主文案同名:同一个项目在不同的运行状态下
|
||||
// 显示两个名字,用户没法把"标签上那个"和"列表里这个"对上。
|
||||
const name =
|
||||
isCurrent && currentName
|
||||
? currentName
|
||||
: activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
return (
|
||||
<li key={`${turn.projectPath}:${turn.turnId}`}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="launcher-runs-titlebar-item"
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
disabled={!onOpenProject}
|
||||
onClick={() => openProject(turn.projectPath)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="launcher-runs-titlebar-item-meta">
|
||||
{[activeTurnStatusLabel(turn.status), elapsed]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<span
|
||||
className="launcher-runs-titlebar-item-name"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="launcher-runs-titlebar-item-meta">
|
||||
{[
|
||||
isCurrent ? '当前' : null,
|
||||
activeTurnStatusLabel(turn.status),
|
||||
elapsed,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runningCount === 0) {
|
||||
// 快照读到了、只是没有在跑回合:整块不渲染,不能报成读取失败。
|
||||
if (!readFailed) {
|
||||
return null;
|
||||
}
|
||||
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<span className="launcher-runs-panel-note" role="status">
|
||||
未能读取正在运行的项目
|
||||
</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<header className="launcher-runs-panel-header">
|
||||
@@ -211,8 +261,6 @@ export function ActiveProjectRunsPanel({
|
||||
</header>
|
||||
<ul className="launcher-runs-panel-list">
|
||||
{orderedTurns.map((turn) => {
|
||||
const name = activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
const isCurrent = Boolean(
|
||||
currentProjectPath &&
|
||||
projectPathsMatchForInvalidation(
|
||||
@@ -220,6 +268,11 @@ export function ActiveProjectRunsPanel({
|
||||
currentProjectPath,
|
||||
),
|
||||
);
|
||||
const name =
|
||||
isCurrent && currentName
|
||||
? currentName
|
||||
: activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
return (
|
||||
<li key={`${turn.projectPath}:${turn.turnId}`}>
|
||||
<button
|
||||
@@ -233,7 +286,11 @@ export function ActiveProjectRunsPanel({
|
||||
{name}
|
||||
</span>
|
||||
<span className="launcher-runs-panel-meta">
|
||||
{[activeTurnStatusLabel(turn.status), elapsed]
|
||||
{[
|
||||
isCurrent ? '当前' : null,
|
||||
activeTurnStatusLabel(turn.status),
|
||||
elapsed,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
|
||||
@@ -301,16 +301,31 @@ export function WorkspaceLauncherShell({
|
||||
openActiveProjectRef.current = openActiveProject;
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* 只在项目视图发布"当前项目"身份。
|
||||
*
|
||||
* `currentProjectContext` 是会话级状态:离开工作台(首页 / 项目组 / 模板库)不会被清空,
|
||||
* 而活动回合快照是全局轮询。若照搬上下文,回首页后标签会一直是「当前项目:<上次打开的项目>」,
|
||||
* 徽标数字却属于别的项目;和同文件里按 `launcherView` 门控的窗口标题 effect 也对不上。
|
||||
*/
|
||||
const inProjectView = launcherView === 'project-development';
|
||||
setActiveProjectRuns({
|
||||
activeTurns,
|
||||
currentProjectPath: currentProjectContext?.projectPath ?? null,
|
||||
currentProjectPath: inProjectView
|
||||
? (currentProjectContext?.projectPath ?? null)
|
||||
: null,
|
||||
currentProjectName: inProjectView
|
||||
? (currentProjectContext?.projectName ?? null)
|
||||
: null,
|
||||
readFailed: snapshotReadFailed,
|
||||
onOpenProject: (projectPath: string) =>
|
||||
openActiveProjectRef.current(projectPath),
|
||||
});
|
||||
}, [
|
||||
activeTurns,
|
||||
currentProjectContext?.projectName,
|
||||
currentProjectContext?.projectPath,
|
||||
launcherView,
|
||||
setActiveProjectRuns,
|
||||
snapshotReadFailed,
|
||||
]);
|
||||
|
||||
@@ -507,6 +507,16 @@ body {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 读取失败时菜单里的那一行结论:只说明"没读到",不顶掉标签上的当前项目名,
|
||||
* 也不改写成业务、权限或审批失败。
|
||||
*/
|
||||
.launcher-runs-titlebar-menu-note {
|
||||
margin: 8px 5px 2px;
|
||||
color: var(--platform-text-muted, #a38f80);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.launcher-runs-titlebar-list {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
@@ -1116,6 +1126,12 @@ textarea {
|
||||
box-shadow: 0 0 0 3px rgb(77 159 84 / 14%);
|
||||
}
|
||||
|
||||
/* 快照读取失败:圆点改成告警色,文字结论留在展开菜单里,不占用标题栏主文案。 */
|
||||
.launcher-runs-panel-dot.is-warning {
|
||||
background: #c7653d;
|
||||
box-shadow: 0 0 0 3px rgb(199 101 61 / 14%);
|
||||
}
|
||||
|
||||
.launcher-runs-panel-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
@@ -33,15 +33,24 @@ function TitleSetter({ value }: { value: string }) {
|
||||
|
||||
function ActiveRunsSetter({
|
||||
activeTurns,
|
||||
currentProjectName,
|
||||
currentProjectPath,
|
||||
}: {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
currentProjectName?: string;
|
||||
currentProjectPath?: string;
|
||||
}) {
|
||||
const { setActiveProjectRuns } = useWindowChrome();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveProjectRuns({ activeTurns, onOpenProject: () => undefined })
|
||||
setActiveProjectRuns({
|
||||
activeTurns,
|
||||
...(currentProjectName ? { currentProjectName } : {}),
|
||||
...(currentProjectPath ? { currentProjectPath } : {}),
|
||||
onOpenProject: () => undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
显示运行项目
|
||||
@@ -155,15 +164,52 @@ 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);
|
||||
});
|
||||
|
||||
/**
|
||||
* 回归:进项目后当前项目没在跑、后台别的项目在跑时,标题栏曾经整块换成
|
||||
* 「正在运行的项目」,当前项目名在窗口上消失。
|
||||
*/
|
||||
it('keeps the current project in the title bar while another project runs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WindowChrome>
|
||||
<ActiveRunsSetter
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/other',
|
||||
projectName: '别的项目',
|
||||
turnId: 'turn-other',
|
||||
startedAt: 100,
|
||||
status: 'running',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
]}
|
||||
currentProjectName="飞鸟 v2"
|
||||
currentProjectPath="C:/projects/bird"
|
||||
/>
|
||||
</WindowChrome>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '显示运行项目' }));
|
||||
|
||||
expect(screen.getByText('飞鸟 v2')).toBeTruthy();
|
||||
expect(screen.queryByText('别的项目')).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '当前项目:飞鸟 v2;共 1 个项目在运行',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* 回归:发布面板等 ThemedModal 弹窗打开时,标题栏在模态之外,焦点陷阱曾把
|
||||
* 标题栏上的点击一起拦下 —— 三个窗口按钮看着正常但点不动。
|
||||
|
||||
@@ -308,7 +308,17 @@ describe('ActiveProjectRunsPanel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
|
||||
/**
|
||||
* 回归:快照读到了、只是没有在跑回合时,面板形态曾把这个健康空态渲染成
|
||||
* 「未能读取正在运行的项目」,把"没在跑"说成"没读到"。
|
||||
*/
|
||||
it('面板形态下快照读取正常但没有在跑回合时整块不渲染', () => {
|
||||
const { container } = render(<ActiveProjectRunsPanel activeTurns={[]} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('不在项目内时标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
|
||||
const onOpenProject = vi.fn();
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
@@ -345,4 +355,92 @@ describe('ActiveProjectRunsPanel', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
|
||||
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
|
||||
});
|
||||
|
||||
/**
|
||||
* 回归:进入的项目没在跑、后台别的项目在跑时,旧实现在标题栏显示的是
|
||||
* 最后开始的那个项目(当前项目名整个消失),用户会以为自己开错了项目。
|
||||
*/
|
||||
it('进入项目后标签显示当前项目名,后台在跑的项目只进徽标与展开菜单', () => {
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
placement="titlebar"
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/other',
|
||||
projectName: 'gameagent-other',
|
||||
turnId: 'turn-other',
|
||||
startedAt: 100,
|
||||
status: 'running',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
]}
|
||||
currentProjectName="飞鸟 v2"
|
||||
currentProjectPath="C:/projects/bird"
|
||||
onOpenProject={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: '当前项目:飞鸟 v2;共 1 个项目在运行',
|
||||
});
|
||||
expect(trigger.textContent).toContain('飞鸟 v2');
|
||||
expect(trigger.textContent).toContain('1');
|
||||
expect(trigger.textContent).not.toContain('gameagent-other');
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: /gameagent-other/ }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('当前项目自己的回合在跑时列表标「当前」并沿用项目名', () => {
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
placement="titlebar"
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/bird',
|
||||
projectName: 'gameagent-bird',
|
||||
turnId: 'turn-bird',
|
||||
startedAt: 100,
|
||||
status: 'streaming',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
]}
|
||||
currentProjectName="飞鸟 v2"
|
||||
currentProjectPath="C:/projects/bird"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /当前项目:飞鸟 v2/ }));
|
||||
const item = screen.getByRole('menuitem');
|
||||
expect(item.textContent).toContain('飞鸟 v2');
|
||||
expect(item.textContent).not.toContain('gameagent-bird');
|
||||
expect(item.textContent).toContain('当前');
|
||||
expect(item.getAttribute('aria-current')).toBe('true');
|
||||
});
|
||||
|
||||
it('读取失败时不顶掉当前项目名,读取结论留在展开菜单里', () => {
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
placement="titlebar"
|
||||
activeTurns={[]}
|
||||
readFailed
|
||||
currentProjectName="飞鸟 v2"
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: /当前项目:飞鸟 v2;正在运行的项目读取失败/,
|
||||
});
|
||||
expect(trigger.textContent).toContain('飞鸟 v2');
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole('menu')).toBeTruthy();
|
||||
expect(screen.getByRole('status').textContent).toBe(
|
||||
'正在运行的项目读取失败',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user