Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx
T
lhk229 70271b408e
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m35s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m39s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m13s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Backend tests (push) Successful in 9m16s
Project CI / Repository checks (push) Successful in 8m11s
Project CI / Native shell tests (push) Successful in 11m56s
Project CI / Frontend tests (push) Successful in 11m49s
Project CI / AI game creator shell web tests (push) Successful in 6m37s
删除策划agent v2,与退役功能解耦 (#355)
Reviewed-on: #355
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-20 14:49:57 +08:00

248 lines
8.3 KiB
TypeScript

import { ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { GameCreatorDirectActiveTurn } from '../../app/types';
import { projectNameFromPath } from '../agent-runtime';
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
/**
* 窗口标题栏的"正在运行的项目"入口,也保留面板布局供独立组件测试和复用。
*
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连),
* 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时
* 整块不渲染,不留空白占位。
*/
export type ActiveProjectRunsPanelProps = {
activeTurns: GameCreatorDirectActiveTurn[];
currentProjectPath?: string | null;
readFailed?: boolean;
onOpenProject?: (projectPath: string) => void;
placement?: 'panel' | 'titlebar';
};
const ACTIVE_TURN_STATUS_LABELS: Record<string, string> = {
accepted: '已受理',
running: '创作中',
streaming: '生成中',
finalizing: '收尾中',
completed: '已完成',
failed: '已失败',
};
function activeTurnStatusLabel(status: string) {
return ACTIVE_TURN_STATUS_LABELS[status] ?? '创作中';
}
function formatActiveTurnElapsed(startedAt: number, now: number) {
const elapsedMs = now - startedAt;
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
return '';
}
const totalMinutes = Math.floor(elapsedMs / 60_000);
if (totalMinutes < 1) {
return '不到 1 分钟';
}
if (totalMinutes < 60) {
return `${totalMinutes} 分钟`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`;
}
function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) {
const snapshotName = turn.projectName?.trim();
return snapshotName || projectNameFromPath(turn.projectPath);
}
export function ActiveProjectRunsPanel({
activeTurns,
currentProjectPath = null,
readFailed = false,
onOpenProject,
placement = 'panel',
}: ActiveProjectRunsPanelProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open || placement !== 'titlebar') {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open, placement]);
if (activeTurns.length === 0) {
if (!readFailed) {
return null;
}
if (placement === 'titlebar') {
return (
<span
className="launcher-runs-titlebar launcher-runs-titlebar--error"
role="status"
>
正在运行的项目读取失败
</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);
const openProject = (projectPath: string) => {
setOpen(false);
onOpenProject?.(projectPath);
};
return (
<div
ref={menuRef}
className="launcher-runs-titlebar"
data-active-project-count={orderedTurns.length}
>
<button
type="button"
className="launcher-runs-titlebar-trigger"
aria-label={`正在运行的项目:${latestName}`}
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>
{orderedTurns.length > 1 ? (
<span className="launcher-runs-titlebar-count">
{orderedTurns.length}
</span>
) : null}
<ChevronDown
className={`launcher-runs-titlebar-chevron${open ? ' is-open' : ''}`}
size={14}
aria-hidden="true"
/>
</button>
{open ? (
<div className="launcher-runs-titlebar-menu" role="menu">
<div className="launcher-runs-titlebar-menu-header">
<strong>正在运行的项目</strong>
<span>{orderedTurns.length} 个</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}
>
{name}
</span>
<span className="launcher-runs-titlebar-item-meta">
{[activeTurnStatusLabel(turn.status), elapsed]
.filter(Boolean)
.join(' · ')}
</span>
</button>
</li>
);
})}
</ul>
</div>
) : null}
</div>
);
}
return (
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
<header className="launcher-runs-panel-header">
<span className="launcher-runs-panel-dot" aria-hidden="true" />
<strong>正在运行</strong>
</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(
turn.projectPath,
currentProjectPath,
),
);
return (
<li key={`${turn.projectPath}:${turn.turnId}`}>
<button
type="button"
className="launcher-runs-panel-item"
aria-current={isCurrent ? 'true' : undefined}
disabled={!onOpenProject}
onClick={() => onOpenProject?.(turn.projectPath)}
>
<span className="launcher-runs-panel-name" title={name}>
{name}
</span>
<span className="launcher-runs-panel-meta">
{[activeTurnStatusLabel(turn.status), elapsed]
.filter(Boolean)
.join(' · ')}
</span>
</button>
</li>
);
})}
</ul>
</aside>
);
}