接通实时清单更新并修复资源焦点竞态

通过统一 Runtime emitter 和受令牌保护的 Runner relay 触发 manifest 重读
为 manifest 重读增加事件合并、项目隔离和迟到响应保护
按稳定资源 ID 重构详情焦点恢复与资源删除 fallback
补充真实 App 事件链、乱序项目切换、媒体焦点和 Rust relay 回归测试
同步更新工作台技术方案、PRD 与共享项目记忆
This commit is contained in:
2026-08-05 12:48:42 +08:00
parent 96c66e619d
commit 4a3ff531fb
18 changed files with 1014 additions and 97 deletions
@@ -2,6 +2,9 @@ use super::*;
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
OnceLock::new();
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
> = OnceLock::new();
pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock<
std::sync::Mutex<std::collections::BTreeMap<String, bool>>,
> = OnceLock::new();
@@ -234,15 +237,21 @@ pub(in crate::agent) use recovery_scan::*;
pub(in crate::agent) use task_queue::*;
pub(in crate::agent) use task_start::*;
#[cfg(test)]
pub(crate) use entrypoints::clear_game_creator_manifest_invalidation_event_sink_for_test;
#[allow(unused_imports)]
pub(crate) use entrypoints::{
chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at,
chat_with_game_creator_role_agent_for_session_at, chat_with_game_creator_role_agent_runtime_at,
chat_with_game_creator_role_agent_runtime_for_session_at,
chat_with_game_creator_role_agent_stream_at,
chat_with_game_creator_role_agent_stream_for_session_at, generate_local_game_draft_at,
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle,
chat_with_game_creator_role_agent_stream_for_session_at,
configure_game_creator_manifest_invalidation_event_sink,
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
set_game_creator_agent_runtime_update_app_handle,
start_game_creator_manifest_invalidation_event_sink,
};
#[cfg(test)]
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
@@ -1,30 +1,145 @@
use super::*;
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
fn lock_game_creator_manifest_invalidation_event_sink(
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) {
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
}
pub(in crate::agent) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) {
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
app: tauri::AppHandle,
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.map_err(|error| format!("绑定 manifest 失效事件接收端失败:{error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("读取 manifest 失效事件接收端失败:{error}"))?
.port();
let token = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let expected_token = token.clone();
thread::Builder::new()
.name("manifest-invalidation-event-sink".to_string())
.spawn(move || {
for incoming in listener.incoming() {
let Ok(mut stream) = incoming else {
continue;
};
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
let mut payload = Vec::new();
let mut limited =
(&mut stream).take(GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + 1);
if limited.read_to_end(&mut payload).is_err()
|| payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES
{
continue;
}
let Ok(envelope) = serde_json::from_slice::<
GameCreatorManifestInvalidationRelayEnvelope,
>(&payload) else {
continue;
};
if envelope.token != expected_token {
continue;
}
let _ = app.emit("game-creator-manifest-invalidated", envelope.event);
}
})
.map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?;
Ok(GameCreatorManifestInvalidationEventSink { port, token })
}
pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
port: u16,
token: &str,
) -> Result<(), String> {
if port == 0 {
return Err("manifest 失效事件接收端口无效".to_string());
}
let token = token.trim();
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("manifest 失效事件接收令牌无效".to_string());
}
*lock_game_creator_manifest_invalidation_event_sink() =
Some(GameCreatorManifestInvalidationEventSink {
port,
token: token.to_string(),
});
Ok(())
}
#[cfg(test)]
pub(crate) fn clear_game_creator_manifest_invalidation_event_sink_for_test() {
*lock_game_creator_manifest_invalidation_event_sink() = None;
}
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
let Some(sink) = sink else {
return Ok(());
};
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
token: sink.token,
event: GameCreatorManifestInvalidatedEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: agent_id.to_string(),
},
};
let payload = serde_json::to_vec(&envelope)
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
if payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES {
return Err("manifest 失效事件超过大小上限".to_string());
}
let address = std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, sink.port).into();
let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100))
.map_err(|error| format!("连接 manifest 失效事件接收端失败:{error}"))?;
stream
.set_write_timeout(Some(Duration::from_millis(100)))
.map_err(|error| format!("配置 manifest 失效事件发送超时失败:{error}"))?;
stream
.write_all(&payload)
.map_err(|error| format!("发送 manifest 失效事件失败:{error}"))
}
pub(crate) fn game_creator_agent_runtime_update_event(
root: &Path,
runtime: AgentRuntimeResult,
) -> GameCreatorAgentRuntimeUpdateEvent {
GameCreatorAgentRuntimeUpdateEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: runtime.state.agent_id.clone(),
run_id: runtime.state.run_id.clone(),
status: runtime.state.status.clone(),
phase: runtime.state.phase.clone(),
manifest_invalidated: true,
runtime,
}
}
pub(crate) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) {
if GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get().is_none() {
let _ = relay_game_creator_manifest_invalidation(root, agent_id);
}
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else {
return;
};
let agent_id = runtime.state.agent_id.clone();
let run_id = runtime.state.run_id.clone();
let status = runtime.state.status.clone();
let phase = runtime.state.phase.clone();
let _ = app.emit(
"game-creator-agent-runtime-update",
GameCreatorAgentRuntimeUpdateEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id,
run_id,
status,
phase,
runtime,
},
game_creator_agent_runtime_update_event(root, runtime),
);
}
@@ -629,9 +629,30 @@ struct GameCreatorAgentRuntimeUpdateEvent {
run_id: String,
status: String,
phase: String,
manifest_invalidated: bool,
runtime: AgentRuntimeResult,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidatedEvent {
project_path: String,
agent_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidationRelayEnvelope {
token: String,
event: GameCreatorManifestInvalidatedEvent,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct GameCreatorManifestInvalidationEventSink {
port: u16,
token: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentProgressEvent {
@@ -2092,7 +2113,10 @@ fn main() {
format!("启动 Agent Runner 失败:{error}"),
)
})?;
attach_external_agent_runner_gui_owner()
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
let manifest_event_sink =
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
attach_external_agent_runner_gui_owner(&manifest_event_sink)
.inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details =
@@ -2113,7 +2137,6 @@ fn main() {
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete");
}
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
#[cfg(all(debug_assertions, not(test)))]
if game_chat_launch.is_none() {
open_developer_window(app.handle())?;
@@ -1,5 +1,8 @@
use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*};
use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog};
use crate::{
AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink,
GameCreatorMcpCatalog,
};
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use std::ffi::OsString;
@@ -911,7 +914,9 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
shutdown_external_agent_runner_at(&config_dir)
}
pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
pub(crate) fn attach_external_agent_runner_gui_owner(
event_sink: &GameCreatorManifestInvalidationEventSink,
) -> Result<(), String> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
.store(true, std::sync::atomic::Ordering::Release);
let config_dir = external_agent_runner_config_dir()
@@ -920,12 +925,18 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
let result = send_external_agent_runner_request(
&endpoint,
"runner.attach_gui_owner",
ExternalAgentRunnerRequestParams::default(),
ExternalAgentRunnerRequestParams {
event_sink_port: Some(event_sink.port),
event_sink_token: Some(event_sink.token.clone()),
..ExternalAgentRunnerRequestParams::default()
},
)?;
if result.get("attached").and_then(Value::as_bool) == Some(true) {
if result.get("attached").and_then(Value::as_bool) == Some(true)
&& result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true)
{
Ok(())
} else {
Err("Agent Runner attach_gui_owner 响应未确认 owner".to_string())
Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string())
}
}
@@ -1181,6 +1192,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
run_id: run_id.map(str::to_string),
action_id: action_id.map(str::to_string),
steer_id: steer_id.map(str::to_string),
event_sink_port: None,
event_sink_token: None,
};
match stable_identity {
Some(stable_identity) => {
@@ -1,4 +1,5 @@
use super::{endpoint::*, project_owner::*, protocol::*, state::*};
use crate::configure_game_creator_manifest_invalidation_event_sink;
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest as _, Sha256};
@@ -587,10 +588,27 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
"runner.attach_gui_owner" => {
match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) {
Ok(true) => {
let event_sink = request
.params
.event_sink_port
.zip(request.params.event_sink_token.as_deref())
.ok_or_else(|| {
"Agent Runner GUI owner 缺少 manifest 事件接收端".to_string()
})
.and_then(|(port, token)| {
configure_game_creator_manifest_invalidation_event_sink(port, token)
});
if let Err(error) = event_sink {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"event-sink-invalid",
redact_runner_secret(&error, &token),
);
}
state.gui_owner_attached.store(true, Ordering::Release);
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "attached": true }),
json!({ "attached": true, "eventSinkAttached": true }),
)
}
Ok(false) => ExternalAgentRunnerResponse::failure(
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 4;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5;
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
@@ -264,6 +264,10 @@ pub(super) struct ExternalAgentRunnerRequestParams {
pub(super) action_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) steer_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_token: Option<String>,
}
#[derive(Deserialize, Serialize)]
@@ -84,7 +84,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m
assert!(
external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
);
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 4);
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5);
}
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
@@ -583,7 +583,11 @@ fn attached_gui_owner_loss_forces_runner_shutdown() {
request_id: "gui-owner-attach-1".to_string(),
token: token.to_string(),
method: "runner.attach_gui_owner".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
params: ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_318),
event_sink_token: Some("b".repeat(64)),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
@@ -599,6 +603,7 @@ fn attached_gui_owner_loss_forces_runner_shutdown() {
assert!(state.draining.load(Ordering::Acquire));
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
assert!(state.shutdown_requested.load(Ordering::Acquire));
crate::clear_game_creator_manifest_invalidation_event_sink_for_test();
}
#[test]
@@ -13,6 +13,49 @@ static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0);
static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000);
static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(());
#[test]
fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_relay() {
let root = unique_project_path();
init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试")
.expect("init runtime event contract project");
let runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan")
.expect("read non-Supervisor runtime");
let event = game_creator_agent_runtime_update_event(&root, runtime);
let serialized = serde_json::to_value(event).expect("serialize runtime update event");
assert_eq!(serialized["agentId"], "art-asset-plan");
assert_eq!(serialized["manifestInvalidated"], true);
let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind manifest invalidation relay fixture");
let relay_port = relay_listener
.local_addr()
.expect("read manifest invalidation relay fixture address")
.port();
let relay_token = "a".repeat(64);
configure_game_creator_manifest_invalidation_event_sink(relay_port, &relay_token)
.expect("configure manifest invalidation relay fixture");
emit_game_creator_agent_runtime_update(&root, "art-asset-plan");
let (mut relay_stream, _) = relay_listener
.accept()
.expect("accept manifest invalidation relay");
relay_stream
.set_read_timeout(Some(Duration::from_secs(1)))
.expect("set manifest invalidation relay read timeout");
let mut relay_payload = Vec::new();
relay_stream
.read_to_end(&mut relay_payload)
.expect("read manifest invalidation relay");
clear_game_creator_manifest_invalidation_event_sink_for_test();
let relay: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&relay_payload).expect("parse manifest invalidation relay");
assert_eq!(relay.token, relay_token);
assert_eq!(relay.event.project_path, root.to_string_lossy());
assert_eq!(relay.event.agent_id, "art-asset-plan");
fs::remove_dir_all(root).ok();
}
#[test]
fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() {
assert!(game_creator_gui_run_event_requests_runner_shutdown(
+118 -19
View File
@@ -53,6 +53,7 @@ import type {
GameCreatorAgentRuntimeUpdateEvent,
GameCreatorChatAgentReply,
GameCreatorLlmConfigStatus,
GameCreatorManifestInvalidatedEvent,
GameCreatorRoleAgentChatStreamEvent,
GenerateLocalGameDraftResult,
ImportCanvasExportResult,
@@ -612,6 +613,16 @@ export function App({
);
const localProjectPathRef = useRef<string | null>(null);
localProjectPathRef.current = localProject?.projectPath ?? null;
const manifestRefreshMountedRef = useRef(true);
const manifestRefreshStatesRef = useRef(
new Map<
string,
{
pending: boolean;
inFlight: Promise<void> | null;
}
>(),
);
const [manifest, setManifest] = useState<GameCreationAppManifest>(
initialProjectManifest ?? seedManifest,
);
@@ -818,6 +829,77 @@ export function App({
const projectSupervisorResponseStreamRef =
useRef<AgentRuntimeResponseStream | null>(null);
projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream;
const refreshManifest = useCallback(
(
nextProjectPath = localProjectPathRef.current ?? '',
): Promise<void> => {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) {
return Promise.resolve();
}
const refreshStates = manifestRefreshStatesRef.current;
let refreshState = refreshStates.get(nextProjectPath);
if (!refreshState) {
refreshState = { pending: false, inFlight: null };
refreshStates.set(nextProjectPath, refreshState);
}
refreshState.pending = true;
if (refreshState.inFlight) {
return refreshState.inFlight;
}
const activeRefreshState = refreshState;
const refreshPromise = (async () => {
try {
while (activeRefreshState.pending) {
activeRefreshState.pending = false;
const projectScopeVersion = projectScopeVersionRef.current;
try {
const nextManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
if (
manifestRefreshMountedRef.current &&
localProjectPathRef.current === nextProjectPath &&
projectScopeVersionRef.current === projectScopeVersion
) {
setManifest(nextManifest);
}
} catch {
// Dev-only convenience; command errors are surfaced by the action that triggered them.
}
if (
!manifestRefreshMountedRef.current ||
localProjectPathRef.current !== nextProjectPath ||
projectScopeVersionRef.current !== projectScopeVersion
) {
activeRefreshState.pending = false;
}
}
} finally {
activeRefreshState.inFlight = null;
if (!activeRefreshState.pending) {
refreshStates.delete(nextProjectPath);
}
}
})();
activeRefreshState.inFlight = refreshPromise;
return refreshPromise;
},
[],
);
useEffect(() => {
const refreshStates = manifestRefreshStatesRef.current;
manifestRefreshMountedRef.current = true;
return () => {
manifestRefreshMountedRef.current = false;
refreshStates.clear();
};
}, []);
const projectSupervisorRuntimeSyncingRef = useRef(new Set<string>());
const projectSupervisorRefreshConversationRef = useRef<
| ((
@@ -1306,6 +1388,9 @@ export function App({
if (payload.projectPath !== localProjectPathRef.current) {
return;
}
if (payload.manifestInvalidated) {
void refreshManifest(payload.projectPath);
}
const nextRuntime = agentRuntimeStateFromResult(payload.runtime);
if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) {
appendGameChatFinalReplyMessages(payload.projectPath, [
@@ -1395,10 +1480,43 @@ export function App({
}, [
appendGameChatFinalReplyMessages,
gameChatOnly,
refreshManifest,
updateProjectSupervisorResponseStream,
updateProjectSupervisorRuntime,
]);
useEffect(() => {
const listen = window.__TAURI__?.event?.listen;
if (!listen) {
return;
}
let cleanup: (() => void) | null = null;
let disposed = false;
void listen<GameCreatorManifestInvalidatedEvent>(
'game-creator-manifest-invalidated',
(event) => {
if (event.payload.projectPath !== localProjectPathRef.current) {
return;
}
void refreshManifest(event.payload.projectPath);
},
)
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
cleanup = unlisten;
})
.catch(() => {
// In-process Runtime events continue to carry the same invalidation signal.
});
return () => {
disposed = true;
cleanup?.();
};
}, [refreshManifest]);
useEffect(() => {
const invoke = resolveTauriInvoke();
const nextProjectPath = localProject?.projectPath ?? null;
@@ -9982,25 +10100,6 @@ export function App({
}
}
async function refreshManifest(
nextProjectPath = resolveChatProjectPath(localProject) ?? '',
) {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) {
return;
}
try {
const nextManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
setManifest(nextManifest);
} catch {
// Dev-only convenience; command errors are surfaced by the action that triggered them.
}
}
async function loadAgentRunTraceFile(
relativePath: string,
nextProjectPath = resolveChatProjectPath(localProject) ?? '',
@@ -426,9 +426,15 @@ export interface GameCreatorAgentRuntimeUpdateEvent {
runId: string;
status: string;
phase: string;
manifestInvalidated: boolean;
runtime: AgentRuntimeResult;
}
export interface GameCreatorManifestInvalidatedEvent {
projectPath: string;
agentId: string;
}
export const gameCreatorLlmReasoningEfforts = [
'default',
'low',
@@ -440,8 +440,12 @@ export default function ProjectDevelopmentView({
});
const [mediaDuration, setMediaDuration] = useState<number | null>(null);
const resourceCanvasRef = useRef<HTMLDivElement>(null);
const resourceSearchRef = useRef<HTMLInputElement>(null);
const resourceFocusRef = useRef<HTMLElement>(null);
const resourceFocusTriggerIdRef = useRef<string | null>(null);
const previousFocusedResourceIdRef = useRef<string | null>(null);
const resourceFocusProjectPathRef = useRef(projectPath);
const suppressResourceFocusRestoreRef = useRef(false);
const resourceListScrollRef = useRef({ left: 0, top: 0 });
const restoreResourceListScrollRef = useRef(false);
const dependencyDescriptionId = useId();
@@ -731,6 +735,10 @@ export default function ProjectDevelopmentView({
resources.find((resource) => resource.id === selectedResourceId) ?? null;
const focusedResource =
resources.find((resource) => resource.id === focusedResourceId) ?? null;
const focusedResourcePath = focusedResource?.path ?? null;
const focusedResourceCategory = focusedResource?.category ?? null;
const focusedResourceContent = focusedResource?.content;
const focusedResourceMediaType = focusedResource?.mediaType ?? null;
const focusedResourceIsImage = Boolean(
focusedResource && isRasterImageResource(focusedResource),
);
@@ -793,12 +801,22 @@ export default function ProjectDevelopmentView({
useEffect(() => {
if (embeddedPreviewUrl) {
suppressResourceFocusRestoreRef.current = true;
restoreResourceListScrollRef.current = false;
resourceFocusTriggerIdRef.current = null;
setFocusedResourceId(null);
setMode('run');
}
}, [embeddedPreviewUrl]);
useEffect(() => {
useLayoutEffect(() => {
if (resourceFocusProjectPathRef.current === projectPath) {
return;
}
resourceFocusProjectPathRef.current = projectPath;
suppressResourceFocusRestoreRef.current = true;
previousFocusedResourceIdRef.current = null;
resourceFocusTriggerIdRef.current = null;
setSelectedResourceId(null);
setFocusedResourceId(null);
resourceListScrollRef.current = { left: 0, top: 0 };
@@ -820,7 +838,11 @@ export default function ProjectDevelopmentView({
}, [focusedResourceId]);
useEffect(() => {
if (!focusedResource || !focusedResourceIsImage) {
if (
!focusedResourceId ||
!focusedResourcePath ||
!focusedResourceIsImage
) {
setImagePreview({ status: 'idle', resourceId: null });
return undefined;
}
@@ -828,23 +850,27 @@ export default function ProjectDevelopmentView({
if (!invoke) {
setImagePreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: '图片预览需要在客户端内打开',
});
return undefined;
}
let cancelled = false;
setImagePreview({ status: 'loading', resourceId: focusedResource.id });
setImagePreview((current) =>
current.resourceId === focusedResourceId
? current
: { status: 'loading', resourceId: focusedResourceId },
);
void invoke<LocalProjectImagePreview>('read_local_project_image_preview', {
projectPath,
relativePath: focusedResource.path,
relativePath: focusedResourcePath,
})
.then((preview) => {
if (!cancelled) {
setImagePreview({
status: 'loaded',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
preview,
});
}
@@ -853,7 +879,7 @@ export default function ProjectDevelopmentView({
if (!cancelled) {
setImagePreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: imagePreviewErrorMessage(error),
});
}
@@ -861,22 +887,31 @@ export default function ProjectDevelopmentView({
return () => {
cancelled = true;
};
}, [focusedResource, focusedResourceIsImage, projectPath]);
}, [
focusedResourceId,
focusedResourceIsImage,
focusedResourcePath,
projectPath,
]);
useEffect(() => {
if (!focusedResource || focusedResource.category !== 'document') {
if (
!focusedResourceId ||
!focusedResourcePath ||
focusedResourceCategory !== 'document'
) {
setTextPreview({ status: 'idle', resourceId: null });
return undefined;
}
if (focusedResource.content !== undefined) {
if (focusedResourceContent !== undefined) {
setTextPreview({
status: 'loaded',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
preview: {
path: focusedResource.path,
mediaType: focusedResource.mediaType,
byteLen: new TextEncoder().encode(focusedResource.content).byteLength,
content: focusedResource.content,
path: focusedResourcePath,
mediaType: focusedResourceMediaType ?? 'text/plain',
byteLen: new TextEncoder().encode(focusedResourceContent).byteLength,
content: focusedResourceContent,
},
});
return undefined;
@@ -885,23 +920,27 @@ export default function ProjectDevelopmentView({
if (!invoke) {
setTextPreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: '文档预览需要在客户端内打开',
});
return undefined;
}
let cancelled = false;
setTextPreview({ status: 'loading', resourceId: focusedResource.id });
setTextPreview((current) =>
current.resourceId === focusedResourceId
? current
: { status: 'loading', resourceId: focusedResourceId },
);
void invoke<LocalProjectTextPreview>('read_local_project_text_preview', {
projectPath,
relativePath: focusedResource.path,
relativePath: focusedResourcePath,
})
.then((preview) => {
if (!cancelled) {
setTextPreview({
status: 'loaded',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
preview,
});
}
@@ -910,7 +949,7 @@ export default function ProjectDevelopmentView({
if (!cancelled) {
setTextPreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: mediaPreviewErrorMessage(error),
});
}
@@ -918,11 +957,20 @@ export default function ProjectDevelopmentView({
return () => {
cancelled = true;
};
}, [focusedResource, projectPath]);
}, [
focusedResourceCategory,
focusedResourceContent,
focusedResourceId,
focusedResourceMediaType,
focusedResourcePath,
projectPath,
]);
useEffect(() => {
if (
!focusedResource ||
!focusedResourceId ||
!focusedResourcePath ||
!focusedResourceCategory ||
(!focusedResourceIsExtendedArtMedia && !focusedResourceIsAudio)
) {
setMediaPreview({ status: 'idle', resourceId: null });
@@ -933,7 +981,7 @@ export default function ProjectDevelopmentView({
if (!invoke) {
setMediaPreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: '媒体预览需要在客户端内打开',
});
return undefined;
@@ -941,17 +989,21 @@ export default function ProjectDevelopmentView({
let cancelled = false;
setMediaDuration(null);
setMediaPreview({ status: 'loading', resourceId: focusedResource.id });
setMediaPreview((current) =>
current.resourceId === focusedResourceId
? current
: { status: 'loading', resourceId: focusedResourceId },
);
void invoke<LocalProjectMediaPreview>('read_local_project_media_preview', {
projectPath,
relativePath: focusedResource.path,
category: focusedResource.category,
relativePath: focusedResourcePath,
category: focusedResourceCategory,
})
.then((preview) => {
if (!cancelled) {
setMediaPreview({
status: 'loaded',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
preview,
});
}
@@ -960,7 +1012,7 @@ export default function ProjectDevelopmentView({
if (!cancelled) {
setMediaPreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: mediaPreviewErrorMessage(error),
});
}
@@ -969,17 +1021,39 @@ export default function ProjectDevelopmentView({
cancelled = true;
};
}, [
focusedResource,
focusedResourceCategory,
focusedResourceId,
focusedResourceIsAudio,
focusedResourceIsExtendedArtMedia,
focusedResourcePath,
projectPath,
]);
useLayoutEffect(() => {
if (focusedResource) {
resourceFocusRef.current?.focus({ preventScroll: true });
if (suppressResourceFocusRestoreRef.current) {
suppressResourceFocusRestoreRef.current = false;
previousFocusedResourceIdRef.current = focusedResourceId;
return;
}
if (focusedResourceId && !focusedResource) {
previousFocusedResourceIdRef.current = null;
resourceFocusTriggerIdRef.current = null;
restoreResourceListScrollRef.current = false;
setSelectedResourceId((current) =>
current === focusedResourceId ? null : current,
);
setFocusedResourceId(null);
resourceSearchRef.current?.focus({ preventScroll: true });
return;
}
if (focusedResourceId && focusedResource) {
if (previousFocusedResourceIdRef.current !== focusedResourceId) {
resourceFocusRef.current?.focus({ preventScroll: true });
}
previousFocusedResourceIdRef.current = focusedResourceId;
return;
}
previousFocusedResourceIdRef.current = null;
if (!restoreResourceListScrollRef.current) {
return;
}
@@ -988,16 +1062,22 @@ export default function ProjectDevelopmentView({
canvas.scrollLeft = resourceListScrollRef.current.left;
canvas.scrollTop = resourceListScrollRef.current.top;
const triggerResourceId = resourceFocusTriggerIdRef.current;
if (triggerResourceId) {
Array.from(
canvas.querySelectorAll<HTMLButtonElement>('[data-resource-id]'),
)
.find((card) => card.dataset.resourceId === triggerResourceId)
?.focus({ preventScroll: true });
const triggerCard = triggerResourceId
? Array.from(
canvas.querySelectorAll<HTMLButtonElement>('[data-resource-id]'),
).find((card) => card.dataset.resourceId === triggerResourceId)
: null;
if (triggerCard) {
triggerCard.focus({ preventScroll: true });
} else {
resourceSearchRef.current?.focus({ preventScroll: true });
}
} else {
resourceSearchRef.current?.focus({ preventScroll: true });
}
resourceFocusTriggerIdRef.current = null;
restoreResourceListScrollRef.current = false;
}, [focusedResource]);
}, [focusedResource, focusedResourceId]);
const handleResourceSelect = useCallback((resourceId: string) => {
const canvas = resourceCanvasRef.current;
@@ -1007,6 +1087,7 @@ export default function ProjectDevelopmentView({
top: canvas.scrollTop,
};
}
suppressResourceFocusRestoreRef.current = false;
resourceFocusTriggerIdRef.current = resourceId;
setSelectedResourceId(resourceId);
setFocusedResourceId(resourceId);
@@ -1021,6 +1102,9 @@ export default function ProjectDevelopmentView({
if (!runAvailable) {
return;
}
suppressResourceFocusRestoreRef.current = true;
restoreResourceListScrollRef.current = false;
resourceFocusTriggerIdRef.current = null;
setFocusedResourceId(null);
setMode('run');
}
@@ -1177,6 +1261,7 @@ export default function ProjectDevelopmentView({
<video
src={mediaPreview.preview.dataUrl}
controls
tabIndex={0}
preload="metadata"
aria-label={`${focusedResource.label} 视频预览`}
onError={() =>
@@ -1218,6 +1303,7 @@ export default function ProjectDevelopmentView({
<audio
src={mediaPreview.preview.dataUrl}
controls
tabIndex={0}
preload="metadata"
aria-label={`${focusedResource.label} 音频播放器`}
onLoadedMetadata={(event) =>
@@ -1354,6 +1440,7 @@ export default function ProjectDevelopmentView({
<label className="game-resource-search">
<Search size={16} aria-hidden="true" />
<input
ref={resourceSearchRef}
type="search"
aria-label="搜索项目资源"
value={searchText}
@@ -334,10 +334,19 @@ function createProjectSupervisorRuntimeHarness({
runId: string;
status: string;
phase: string;
manifestInvalidated: boolean;
runtime: Record<string, unknown>;
};
}) => void)
| null = null;
let manifestInvalidatedHandler:
| ((event: {
payload: {
projectPath: string;
agentId: string;
};
}) => void)
| null = null;
const conversationRecord = (
role: 'user' | 'assistant',
@@ -614,10 +623,16 @@ function createProjectSupervisorRuntimeHarness({
if (eventName === 'game-creator-agent-runtime-update') {
runtimeUpdateHandler = handler;
}
if (eventName === 'game-creator-manifest-invalidated') {
manifestInvalidatedHandler = handler as unknown as typeof manifestInvalidatedHandler;
}
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
}
if (manifestInvalidatedHandler === handler) {
manifestInvalidatedHandler = null;
}
};
},
);
@@ -666,6 +681,7 @@ function createProjectSupervisorRuntimeHarness({
runId: String(state.runId),
status: String(state.status),
phase: String(state.phase),
manifestInvalidated: true,
runtime: runtimeResult(currentRuntime, currentResponseStream),
},
});
@@ -678,10 +694,19 @@ function createProjectSupervisorRuntimeHarness({
runId: String(state.runId ?? ''),
status: String(state.status ?? ''),
phase: String(state.phase ?? ''),
manifestInvalidated: true,
runtime: runtimeResult(state, null),
},
});
},
emitManifestInvalidated(agentId: string) {
manifestInvalidatedHandler?.({
payload: {
projectPath,
agentId,
},
});
},
};
}
@@ -2,6 +2,7 @@ import type { ProjectSupervisorComponentProps } from '../../src/features/app-she
import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher';
import {
act,
App,
cleanup,
createGameCreationAppManifest,
createProjectSupervisorRuntimeHarness,
@@ -172,6 +173,322 @@ export function registerClientHomeTests() {
});
});
it('re-reads the live manifest from a non-Supervisor Runtime event without reopening the project', async () => {
const projectPath = '/tmp/live-runtime-manifest-workbench';
const initialManifest = createGameCreationAppManifest(
'live-runtime-manifest-project',
'真实事件清单项目',
);
const updatedManifest = {
...initialManifest,
tasks: initialManifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
),
assets: [
{
id: 'runtime-live-hero',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/runtime-live-hero.png',
source: {
kind: 'canvas' as const,
taskId: 'art-asset-plan',
resourceId: 'canvas-runtime-live-hero',
},
},
],
versions: [
{
versionId: 'version-runtime-live-1',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [
{ slotId: 'hero', resourceId: 'runtime-live-hero' },
],
createdReason: 'initial' as const,
createdAt: 1,
},
],
};
const runtimeHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
let manifestChanged = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '真实事件清单项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifestChanged ? updatedManifest : initialManifest;
}
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: (args?.resources as Array<{ resourceId: string }>).map(
(resource) => resource.resourceId,
),
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-runtime-manifest-project',
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-runtime-manifest-project',
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: runtimeHarness.listen },
};
render(
React.createElement(WorkspaceLauncherShell, {
currentUser: testAuthUser,
initialView: 'projects',
onLogout: vi.fn(),
ProjectSupervisor: App,
}),
);
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const runButton = await screen.findByRole('tab', { name: '运行' });
expect(runButton.getAttribute('data-unavailable')).toBe('true');
expect(
screen.queryByRole('button', { name: /runtime-live-hero\.png/ }),
).toBeNull();
const manifestReadsBeforeEvent = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
const inspectionsBeforeEvent = invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
).length;
manifestChanged = true;
act(() => {
runtimeHarness.emitManifestInvalidated('art-asset-plan');
});
expect(
await screen.findByRole('button', { name: /runtime-live-hero\.png/ }),
).not.toBeNull();
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length,
).toBeGreaterThan(manifestReadsBeforeEvent);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
).length,
).toBe(inspectionsBeforeEvent);
});
it('does not let a late manifest refresh from the previous project replace the active project', async () => {
const firstProjectPath = '/tmp/live-manifest-project-first';
const secondProjectPath = '/tmp/live-manifest-project-second';
const firstManifest = createGameCreationAppManifest(
'live-manifest-project-first',
'旧项目',
);
const staleFirstManifest = {
...firstManifest,
assets: [
{
id: 'stale-first-asset',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/stale-first.png',
source: { kind: 'generated' as const },
},
],
};
const secondManifest = createGameCreationAppManifest(
'live-manifest-project-second',
'新项目',
);
secondManifest.assets = [
{
id: 'second-asset',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/second.png',
source: { kind: 'generated' },
},
];
let resolveStaleRefresh!: (
manifest: typeof staleFirstManifest,
) => void;
const staleRefresh = new Promise<typeof staleFirstManifest>((resolve) => {
resolveStaleRefresh = resolve;
});
let holdFirstRefresh = false;
let signalFirstRefreshStarted!: () => void;
const firstRefreshStarted = new Promise<void>((resolve) => {
signalFirstRefreshStarted = resolve;
});
const runtimeHarness = createProjectSupervisorRuntimeHarness({
projectPath: firstProjectPath,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
const requestedPath = String(args?.projectPath ?? '');
if (command === 'inspect_local_project_directory') {
return {
projectPath: requestedPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName:
requestedPath === secondProjectPath ? '新项目' : '旧项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
if (requestedPath === secondProjectPath) {
return secondManifest;
}
if (holdFirstRefresh) {
signalFirstRefreshStarted();
return staleRefresh;
}
return firstManifest;
}
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: (args?.resources as Array<{ resourceId: string }>).map(
(resource) => resource.resourceId,
),
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId:
requestedPath === secondProjectPath
? secondManifest.projectId
: firstManifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: String(args?.expectedProjectId ?? ''),
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: runtimeHarness.listen },
};
render(
React.createElement(WorkspaceLauncherShell, {
currentUser: testAuthUser,
initialView: 'projects',
onLogout: vi.fn(),
ProjectSupervisor: App,
}),
);
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: firstProjectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
await screen.findByLabelText('项目开发工作台');
holdFirstRefresh = true;
act(() => {
runtimeHarness.emitManifestInvalidated('code-prototype');
});
await firstRefreshStarted;
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: secondProjectPath },
});
fireEvent.click(
within(screen.getByLabelText('项目目录').closest('form')!).getByRole(
'button',
{ name: '打开' },
),
);
expect(
await screen.findByRole('button', { name: /second\.png/ }),
).not.toBeNull();
act(() => resolveStaleRefresh(staleFirstManifest));
await Promise.resolve();
expect(
screen.queryByRole('button', { name: /stale-first\.png/ }),
).toBeNull();
expect(screen.getByRole('button', { name: /second\.png/ })).not.toBeNull();
});
it('starts from the client home and opens a project in the same window', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
@@ -304,7 +304,11 @@ async function renderGameChatAutoPreviewDriver({
callIndex: number,
) => Promise<GameChatPreviewFixture>;
}) {
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
let backgroundRuntimes: Array<Record<string, unknown>> = [];
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => backgroundRuntimes,
});
const manifest = createGameCreationAppManifest(
projectPath.split(/[\\/]/u).filter(Boolean).at(-1) ?? 'game-chat-race',
'game-chat-race',
@@ -421,14 +425,14 @@ async function renderGameChatAutoPreviewDriver({
};
const emitValidation = async (revision: number, validatedAt: number) => {
harness.setProjectRevision(revision);
const runtime = gameChatPreviewPlaytestRuntime({
parentRunId,
revision,
updatedAt: validatedAt,
});
backgroundRuntimes = [runtime];
await act(async () => {
harness.emitAgentRuntime(
gameChatPreviewPlaytestRuntime({
parentRunId,
revision,
updatedAt: validatedAt,
}),
);
harness.emitAgentRuntime(runtime);
await Promise.resolve();
});
};
@@ -505,7 +509,7 @@ async function assertNewGameChatAuthorizationSupersedesDeferredAttempt(
: 'start_local_game_preview'),
);
expect(reachedDeferredStage).toBe(true);
});
}, { timeout: 2_500 });
const firstAuthorization = driver.readAuthorization();
expect(firstAuthorization?.authorizationId).toEqual(expect.any(String));
@@ -1148,6 +1152,130 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
});
it('preserves internal media focus across same-resource manifest updates and falls back when the resource is deleted', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-focus-updates',
'资源焦点更新测试',
);
manifest.assets = [
{
id: 'focus-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/focus.mp3',
source: { kind: 'generated' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'read_local_project_media_preview') {
return {
path: 'assets/focus.mp3',
mediaType: 'audio/mpeg',
byteLen: 1024,
dataUrl: 'data:audio/mpeg;base64,SUQz',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-resource-focus-updates',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest,
}),
);
fireEvent.click(
await screen.findByRole('button', { name: /focus\.mp3/ }),
);
const audio = (await screen.findByLabelText(
'focus.mp3 音频播放器',
)) as HTMLAudioElement;
audio.focus();
expect(document.activeElement).toBe(audio);
const updatedManifest = {
...manifest,
tasks: manifest.tasks.map((task) =>
task.id === 'audio-director'
? { ...task, status: 'completed' as const }
: task,
),
assets: manifest.assets.map((asset) => ({
...asset,
source: { ...asset.source, taskId: 'audio-director' },
})),
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: updatedManifest,
}),
);
expect(document.activeElement).toBe(audio);
expect(
screen.getByRole('region', { name: 'focus.mp3' }),
).not.toBe(document.activeElement);
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: { ...updatedManifest, assets: [] },
}),
);
await waitFor(() => {
expect(screen.queryByRole('region', { name: 'focus.mp3' })).toBeNull();
expect(document.activeElement).toBe(
screen.getByLabelText('搜索项目资源'),
);
});
expect(
screen
.queryAllByTitle('打开资源详情')
.some((card) => card.getAttribute('aria-pressed') === 'true'),
).toBe(false);
});
it('renders, filters, and destroys persistent resource dependency lines without cross-section task flows', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-graph',