补齐 Direct 回合跨页面恢复
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 4m25s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m57s
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 4m25s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m57s
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
新增活动 Direct 回合只读快照与状态更新 接入重进项目的忙碌态恢复和运行中项目面板 补充生命周期规范、决策记录与定向测试
This commit is contained in:
@@ -5,6 +5,7 @@ use std::future::Future;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
mod user_input;
|
||||
pub(crate) use user_input::{chat_with_game_creator_direct_codex, normalize_direct_client_turn_id};
|
||||
@@ -266,6 +267,25 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String
|
||||
#[derive(Debug)]
|
||||
struct DirectTaonierActiveInvocation {
|
||||
invocation_id: String,
|
||||
project_name: Option<String>,
|
||||
started_at: u64,
|
||||
status: String,
|
||||
activity: Option<String>,
|
||||
updated_at: u64,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectActiveTurnSnapshot {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) project_name: Option<String>,
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) started_at: u64,
|
||||
pub(crate) status: String,
|
||||
pub(crate) activity: Option<String>,
|
||||
pub(crate) updated_at: u64,
|
||||
pub(crate) sequence: u64,
|
||||
}
|
||||
|
||||
static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock<
|
||||
@@ -303,6 +323,18 @@ impl DirectTaonierActiveInvocationGuard {
|
||||
root.clone(),
|
||||
DirectTaonierActiveInvocation {
|
||||
invocation_id: invocation_id.to_string(),
|
||||
project_name: root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_string),
|
||||
started_at: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default(),
|
||||
status: "accepted".to_string(),
|
||||
activity: Some("request-accepted".to_string()),
|
||||
updated_at: 0,
|
||||
sequence: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -331,6 +363,57 @@ impl Drop for DirectTaonierActiveInvocationGuard {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn list_direct_active_turns() -> Result<Vec<DirectActiveTurnSnapshot>, String> {
|
||||
let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.map_err(|_| "Direct 调用身份锁已损坏".to_string())?;
|
||||
let mut turns = active
|
||||
.iter()
|
||||
.map(|(root, invocation)| DirectActiveTurnSnapshot {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
project_name: invocation.project_name.clone(),
|
||||
turn_id: invocation.invocation_id.clone(),
|
||||
started_at: invocation.started_at,
|
||||
status: invocation.status.clone(),
|
||||
activity: invocation.activity.clone(),
|
||||
updated_at: invocation.updated_at,
|
||||
sequence: invocation.sequence,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
turns.sort_by(|left, right| left.project_path.cmp(&right.project_path));
|
||||
Ok(turns)
|
||||
}
|
||||
|
||||
pub(crate) fn update_direct_active_turn(
|
||||
root: &Path,
|
||||
turn_id: &str,
|
||||
status: &str,
|
||||
activity: Option<&str>,
|
||||
sequence: u64,
|
||||
updated_at: u64,
|
||||
) {
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
return;
|
||||
};
|
||||
let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut active) = active.lock() else {
|
||||
return;
|
||||
};
|
||||
let Some(invocation) = active.get_mut(&root) else {
|
||||
return;
|
||||
};
|
||||
if invocation.invocation_id != turn_id || sequence < invocation.sequence {
|
||||
return;
|
||||
}
|
||||
invocation.status = status.to_string();
|
||||
invocation.activity = activity.map(str::to_string);
|
||||
invocation.updated_at = updated_at;
|
||||
invocation.sequence = sequence;
|
||||
}
|
||||
|
||||
pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result<String, String> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
@@ -4766,6 +4849,36 @@ mod tests {
|
||||
.expect("lost-response replay after the original turn finishes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() {
|
||||
let root = tempfile::tempdir().expect("active snapshot root");
|
||||
let turn_id = "client-turn-snapshot-0001";
|
||||
let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id)
|
||||
.expect("active snapshot turn");
|
||||
update_direct_active_turn(
|
||||
root.path(),
|
||||
turn_id,
|
||||
"streaming",
|
||||
Some("response-finalization"),
|
||||
3,
|
||||
42,
|
||||
);
|
||||
let snapshot = list_direct_active_turns()
|
||||
.expect("list active turns")
|
||||
.into_iter()
|
||||
.find(|turn| turn.turn_id == turn_id)
|
||||
.expect("snapshot entry");
|
||||
assert_eq!(snapshot.status, "streaming");
|
||||
assert_eq!(snapshot.activity.as_deref(), Some("response-finalization"));
|
||||
assert_eq!(snapshot.sequence, 3);
|
||||
assert_eq!(snapshot.updated_at, 42);
|
||||
drop(guard);
|
||||
assert!(list_direct_active_turns()
|
||||
.expect("list after completion")
|
||||
.into_iter()
|
||||
.all(|turn| turn.turn_id != turn_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -74,15 +74,23 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
if !status_is_allowed || !activity_is_allowed {
|
||||
return;
|
||||
}
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
let updated_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64;
|
||||
update_direct_active_turn(
|
||||
Path::new(&self.project_path),
|
||||
&self.turn_id,
|
||||
status,
|
||||
activity,
|
||||
sequence,
|
||||
updated_at,
|
||||
);
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
let _ = app.emit(
|
||||
"game-creator-direct-turn-update",
|
||||
GameCreatorDirectTurnUpdateEvent {
|
||||
|
||||
@@ -5279,6 +5279,12 @@ pub(crate) async fn read_agent_runtime_error_detail(
|
||||
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_game_creator_direct_active_turns(
|
||||
) -> Result<Vec<DirectActiveTurnSnapshot>, String> {
|
||||
list_direct_active_turns()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn subscribe_direct_project_thread(
|
||||
project_path: String,
|
||||
|
||||
@@ -2770,6 +2770,7 @@ fn main() {
|
||||
read_local_conversation,
|
||||
read_direct_project_conversation,
|
||||
read_agent_runtime_error_detail,
|
||||
list_game_creator_direct_active_turns,
|
||||
subscribe_direct_project_thread,
|
||||
consume_direct_project_thread,
|
||||
read_direct_project_history_slice,
|
||||
|
||||
@@ -57,6 +57,7 @@ import type {
|
||||
DesignView,
|
||||
GameCreatorAgentRuntimeUpdateEvent,
|
||||
GameCreatorChatAgentReply,
|
||||
GameCreatorDirectActiveTurn,
|
||||
GameCreatorDirectTurnUpdateEvent,
|
||||
GameCreatorLlmConfigStatus,
|
||||
GameCreatorManifestInvalidatedEvent,
|
||||
@@ -1955,6 +1956,29 @@ export function App({
|
||||
};
|
||||
|
||||
const bootstrap = async () => {
|
||||
try {
|
||||
const activeTurns = await directInvoke<GameCreatorDirectActiveTurn[]>(
|
||||
'list_game_creator_direct_active_turns',
|
||||
);
|
||||
const activeTurn = activeTurns.find((turn) =>
|
||||
projectPathsMatchForInvalidation(turn.projectPath, projectPath),
|
||||
);
|
||||
if (activeTurn && !activeDirectCodexTurnRef.current) {
|
||||
activeDirectCodexTurnRef.current = {
|
||||
projectPath,
|
||||
turnId: activeTurn.turnId,
|
||||
lastSequence: 0,
|
||||
receivedDirectUpdate: true,
|
||||
};
|
||||
setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`);
|
||||
setChatAgentBusy(true);
|
||||
setDirectCodexStatus('running');
|
||||
setDirectCodexProgress('正在处理');
|
||||
setDirectCodexProgressUpdatedAt(Date.now());
|
||||
}
|
||||
} catch {
|
||||
// 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。
|
||||
}
|
||||
const result = await directInvoke<DirectThreadSubscriptionBootstrap>(
|
||||
'subscribe_direct_project_thread',
|
||||
{ projectPath },
|
||||
|
||||
@@ -1258,3 +1258,14 @@ export type TauriInvoke = <T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<T>;
|
||||
|
||||
export type GameCreatorDirectActiveTurn = {
|
||||
projectPath: string;
|
||||
projectName?: string | null;
|
||||
turnId: string;
|
||||
startedAt: number;
|
||||
status: string;
|
||||
activity?: string | null;
|
||||
updatedAt: number;
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types';
|
||||
|
||||
/**
|
||||
* 轮询间隔:注册表是进程内只读快照,一次查询只是一次 IPC + 一次内存遍历。
|
||||
* "哪些项目正在跑"不值得再建一套事件流,而且轮询能在丢事件时自愈。
|
||||
*/
|
||||
export const DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* 单次刷新里的读取尝试次数。快照读取失败最多重试 3 次,3 次全部失败才把
|
||||
* "读不到"告诉用户;但即便告诉,也只能说读取失败,不得改写成业务失败、
|
||||
* 权限问题或审批结论。
|
||||
*/
|
||||
export const DIRECT_ACTIVE_TURNS_READ_ATTEMPTS = 3;
|
||||
const DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS = 300;
|
||||
|
||||
/**
|
||||
* 当前进程里仍在跑的 Direct 回合。
|
||||
*
|
||||
* 回合属于项目而不是页面:离开项目界面不会终止它,所以"谁在跑"必须从 Rust 的
|
||||
* 活动回合注册表读,而不是从当前页面的组件状态推断。读取失败保留上一份快照——
|
||||
* 读不到不等于"没有在跑",调用方不能据此阻断发送或清空状态。
|
||||
*/
|
||||
export function useDirectActiveTurns({
|
||||
invoke,
|
||||
enabled,
|
||||
pollIntervalMs = DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
|
||||
}: {
|
||||
invoke: TauriInvoke | null | undefined;
|
||||
enabled: boolean;
|
||||
pollIntervalMs?: number;
|
||||
}) {
|
||||
const [activeTurns, setActiveTurns] = useState<GameCreatorDirectActiveTurn[]>(
|
||||
[],
|
||||
);
|
||||
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
const inFlightRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshActiveTurns = useCallback(async () => {
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
// 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。
|
||||
if (inFlightRef.current) {
|
||||
return inFlightRef.current;
|
||||
}
|
||||
const request = (async () => {
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= DIRECT_ACTIVE_TURNS_READ_ATTEMPTS;
|
||||
attempt++
|
||||
) {
|
||||
try {
|
||||
const turns = await invoke<GameCreatorDirectActiveTurn[]>(
|
||||
'list_game_creator_direct_active_turns',
|
||||
);
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setActiveTurns(Array.isArray(turns) ? turns : []);
|
||||
setSnapshotReadFailed(false);
|
||||
inFlightRef.current = null;
|
||||
return;
|
||||
} catch {
|
||||
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(
|
||||
resolve,
|
||||
DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。
|
||||
if (mountedRef.current) {
|
||||
setSnapshotReadFailed(true);
|
||||
}
|
||||
inFlightRef.current = null;
|
||||
})();
|
||||
inFlightRef.current = request;
|
||||
return request;
|
||||
}, [invoke]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !invoke) {
|
||||
setActiveTurns([]);
|
||||
setSnapshotReadFailed(false);
|
||||
return;
|
||||
}
|
||||
void refreshActiveTurns();
|
||||
const timer = window.setInterval(
|
||||
() => void refreshActiveTurns(),
|
||||
Math.max(1_000, pollIntervalMs),
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [enabled, invoke, pollIntervalMs, refreshActiveTurns]);
|
||||
|
||||
return { activeTurns, refreshActiveTurns, snapshotReadFailed };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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,
|
||||
}: ActiveProjectRunsPanelProps) {
|
||||
if (activeTurns.length === 0) {
|
||||
if (!readFailed) {
|
||||
return null;
|
||||
}
|
||||
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
|
||||
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,
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
type ProjectManifestSnapshotSource,
|
||||
rereadAuthoritativeProjectManifestSnapshot,
|
||||
} from '../../view/project-development/projectResourceLiveUpdateModel';
|
||||
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel';
|
||||
import {
|
||||
DeveloperAgentDialogs,
|
||||
DeveloperAgentPanel,
|
||||
@@ -96,6 +98,11 @@ export function WorkspaceLauncherShell({
|
||||
homeCreationBusy,
|
||||
homeCreationRecoverableProjectPath,
|
||||
} = homeProject;
|
||||
const directInvoke = resolveTauriInvoke();
|
||||
const { activeTurns, snapshotReadFailed } = useDirectActiveTurns({
|
||||
invoke: directInvoke,
|
||||
enabled: true,
|
||||
});
|
||||
const switchedToGameRuntime =
|
||||
gameRuntimeSwitch !== null &&
|
||||
currentProjectContext !== null &&
|
||||
@@ -517,6 +524,16 @@ export function WorkspaceLauncherShell({
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={activeTurns}
|
||||
currentProjectPath={currentProjectContext?.projectPath ?? null}
|
||||
readFailed={snapshotReadFailed}
|
||||
onOpenProject={(nextProjectPath) => {
|
||||
setProjectPath(nextProjectPath);
|
||||
void openProject(nextProjectPath, 'open');
|
||||
}}
|
||||
/>
|
||||
|
||||
{launcherView === 'home' ? (
|
||||
<HomeView
|
||||
hasPromo={launcherNotifications.length > 0}
|
||||
|
||||
@@ -903,6 +903,80 @@ textarea {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.launcher-runs-panel {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 24px;
|
||||
z-index: 3;
|
||||
width: min(280px, calc(100% - 260px));
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent);
|
||||
box-shadow: 0 8px 24px rgb(31 24 16 / 8%);
|
||||
}
|
||||
|
||||
.launcher-runs-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.launcher-runs-panel-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #4d9f54;
|
||||
box-shadow: 0 0 0 3px rgb(77 159 84 / 14%);
|
||||
}
|
||||
|
||||
.launcher-runs-panel-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 7px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.launcher-runs-panel-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 2px;
|
||||
padding: 6px 7px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-runs-panel-item:hover,
|
||||
.launcher-runs-panel-item[aria-current='true'] {
|
||||
background: rgb(255 255 255 / 72%);
|
||||
}
|
||||
|
||||
.launcher-runs-panel-item:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.launcher-runs-panel-name {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-runs-panel-meta,
|
||||
.launcher-runs-panel-note {
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.launcher-promo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('按开始时间展示正在运行的项目并支持进入项目', () => {
|
||||
const onOpenProject = vi.fn();
|
||||
render(
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={[
|
||||
{
|
||||
projectPath: 'C:/projects/later',
|
||||
projectName: '后开始',
|
||||
turnId: 'turn-later',
|
||||
startedAt: 200,
|
||||
status: 'streaming',
|
||||
updatedAt: 220,
|
||||
sequence: 2,
|
||||
},
|
||||
{
|
||||
projectPath: 'C:/projects/first',
|
||||
projectName: '先开始',
|
||||
turnId: 'turn-first',
|
||||
startedAt: 100,
|
||||
status: 'running',
|
||||
updatedAt: 120,
|
||||
sequence: 1,
|
||||
},
|
||||
]}
|
||||
onOpenProject={onOpenProject}
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = screen.getAllByRole('button');
|
||||
expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
fireEvent.click(items[0]);
|
||||
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
|
||||
});
|
||||
|
||||
it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
|
||||
render(<ActiveProjectRunsPanel activeTurns={[]} readFailed />);
|
||||
|
||||
expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目');
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
# 【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15
|
||||
|
||||
Version: 1
|
||||
Status: in-progress
|
||||
Date: 2026-09-15
|
||||
Milestone: `【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md`
|
||||
|
||||
## 固定契约
|
||||
|
||||
只读快照命令(Tauri 本地命令,`src-tauri/src/agent/direct_runtime/mod.rs`):
|
||||
|
||||
- `list_game_creator_direct_active_turns() -> Vec<GameCreatorDirectActiveTurn>`
|
||||
- 字段(camelCase):`projectPath`、`projectName`、`turnId`、`status`、`activity`(可空)、`startedAt`、`updatedAt`、`sequence`
|
||||
- `status` 取值集合与既有 Direct 回合事件一致:`accepted` / `running` / `streaming` / `finalizing` / `completed` / `failed`
|
||||
|
||||
身份锁与快照共用同一份进程内注册表;注册表条目在回合进入时写入 `startedAt` 与 `projectName`,在每次回合事件发射时更新 `status` / `activity` / `sequence` / `updatedAt`,在回合结束(guard drop)时移除。
|
||||
|
||||
## 代码边界
|
||||
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs`:注册表结构扩展、快照读写、新命令、Rust 定向测试
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs`:事件发射时投影到注册表
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/main.rs`:命令注册
|
||||
- `apps/ai-game-creator-shell/src/App.tsx`:项目打开时重连、忙碌态与进度恢复、面板挂载
|
||||
- `apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`:快照轮询与单飞刷新(面板与重连共用)
|
||||
- 面板组件(新文件,落在既有 feature 目录下)+ 对应测试
|
||||
- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts` + 测试:报错归类修正
|
||||
|
||||
## 修改顺序
|
||||
|
||||
1. Rust:扩展活动回合注册表并暴露只读快照命令,配定向用例(进入 / 进度 / 终态移除 / 多项目并存)。
|
||||
2. 前端:接入快照读取,实现“重新进入项目 → 恢复忙碌态与进度 → 以快照 sequence 续接 → 阻止并发提交”。
|
||||
3. 前端:在左上角空白区域挂载“正在运行的项目”面板,复用既有组件与设计 token。
|
||||
4. 报错归类:按审计结论修正会误导的映射,逐条加回归用例;真实权限拒绝保持原提示。
|
||||
5. 文档:主规范与共享记忆同步;里程碑验收后删除临时计划文件。
|
||||
|
||||
## 验证命令
|
||||
|
||||
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_active_turns -- --test-threads=1`(名称按实际用例调整)
|
||||
- `npx vitest run apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts`
|
||||
- `npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`
|
||||
- 面板组件测试文件单独一条 vitest
|
||||
- `npm --prefix apps/ai-game-creator-shell run typecheck`
|
||||
- `npm run check:encoding`、`git diff --check`
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- 快照命令暴露项目绝对路径给前端:与现有 `projectPath` 口径一致,不得额外泄露配置或 token;命令必须是只读、无副作用。
|
||||
- 续接基线 `sequence` 若取错,会让重新进入后的进度事件被丢弃或重复消费;取错时回滚“重连”部分,保留只读面板。
|
||||
- 忙碌态恢复不得与既有 `chatAgentBusy` 的失败清理互相覆盖;出现卡死忙碌态时优先回滚重连,不影响身份锁与后台回合本体。
|
||||
- 面板若在窄窗口挤压主内容,先按既有响应式约定隐藏面板,不改主布局。
|
||||
- 报错归类修正若与既有断言冲突,先确认断言锁的是“正确行为”还是历史错误文案,再决定改断言还是改实现。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15
|
||||
|
||||
Version: 1
|
||||
Status: in-progress
|
||||
Date: 2026-09-15
|
||||
Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`「2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性」
|
||||
|
||||
## 目标
|
||||
|
||||
离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层左上角提供“正在运行的项目”面板,列出当前确有在跑回合的项目并可点击进入。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只读投影:新增命令只读当前 GUI 进程内的活动回合注册表,不写项目文件、不新增持久化账本。
|
||||
- 不新增取消入口;不改变身份锁排他性、项目写锁语义、计费与幂等身份。
|
||||
- 不新增跨端契约(Tauri 本地命令,不进 `packages/shared` / `shared-contracts` / OpenAPI)。
|
||||
- 面板与重连共用同一份快照,不各自维护第二份“谁在跑”的真相。
|
||||
- 报错归类修正只处理“说明与真相无关”的情况,不放宽身份锁、不吞真实失败。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 重新进入有在跑回合的项目后:界面进入“正在处理”、显示最近一次进度、以快照 `sequence` 续接后续事件;回合结束前提交第二条需求不会真正发起第二条付费回合。
|
||||
- 回合结束(completed / failed)后:忙碌态解除、可以再次发送;不重复追加助手消息。
|
||||
- 无在跑回合的项目:行为与今天一致(可正常发送,不出现额外提示或阻塞)。
|
||||
- 左上角面板:列出所有在跑项目,按 `startedAt` 升序,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染面板外壳。
|
||||
- 快照读取失败不得阻断发送、不得显示成业务失败。
|
||||
- 已修的错误映射不回归:`direct-codex-turn-already-running:` 与历史同义中文正文都归一到“仍在处理这个项目的上一条需求”;真正的 `项目权限策略拒绝执行:<command>` 仍显示审批提示。
|
||||
|
||||
## 未决事项
|
||||
|
||||
- “离开页面即取消”仍是未采纳的另一种语义;本轮只实现后台继续。
|
||||
- 应用重启后的“未完成回合”恢复不在本里程碑范围(回合注册表是进程内状态);若未来要求跨重启恢复,需要另立里程碑并定义持久化身份与对账合同。
|
||||
- 面板是否需要展示非 Direct(专业 Agent / 策划 Agent)运行中的项目,本轮不做;先把 Direct 回合这条事实链路做正确。
|
||||
@@ -8759,4 +8759,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/<eventId>.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。
|
||||
- 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。
|
||||
- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。
|
||||
## 2026-09-15 Direct 回合跨页面继续运行与活动项目面板
|
||||
|
||||
- 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。
|
||||
- 边界:快照不写项目文件、不进入公共 API、不跨应用重启恢复;读取失败保留上一份结果并单独提示,不改写成权限或审批失败。身份锁排他性、付费身份和项目写锁不变。
|
||||
|
||||
@@ -1397,3 +1397,11 @@ DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执
|
||||
游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。
|
||||
|
||||
验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。
|
||||
|
||||
## 2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性
|
||||
|
||||
Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。
|
||||
|
||||
壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照,按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。
|
||||
|
||||
活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。
|
||||
|
||||
Reference in New Issue
Block a user