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
Reviewed-on: #355 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
248 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|