fix(agc): 「定位到素材」每次点击都终局化,不再卡在「正在定位生成的素材…」

- index.tsx 新增 `resourceAssetGenerationFocusRequest` 序号并加进聚焦 effect 的依赖:手动点定位不改画布
  任何状态,原依赖一个都不变 → effect 不重跑、intent 永远没人消费,这正是「点了没反应 + 提示条永久
  停在中转文案」的根因
- index.tsx `focusResourceAssetGenerationTask` 重写:先按当前投影与清单判一次目标——不在投影里就当场给
  结论(素材已不在项目里 / 已登记但尚未同步),不挂 intent 也不留中转提示;在别的栏目先切栏目再定位;
  挂 intent 后推进聚焦请求序号,并起一个 3 秒有界兜底:仍停在中转文案就收口成可执行提示,intent 被判
  invalid 被静默清掉时也给一条「定位请求已失效」的结论,绝不留下悬而未决的状态
- index.tsx 聚焦 effect 的 `focusedCommitIdsRef` 提前返回分支补 `setResourceWorkbenchNotice('')`:
  已聚焦过时把中转提示一并收掉,不再永久留在提示条上(自动落卡那条链同源问题)
- index.tsx `resourceAssetGenerationContextRef` 扩到带 `manifest` / `resources` / `activePageCategory`
  并在每次渲染刷新:定位回调据此取权威目标与栏目,不再依赖可能过期的闭包值
- 新增 4 条 AppSurface 用例(同栏目直点定位并选中卡片、素材在另一个栏目先切栏目再定位、被搜索挡住时给出
  既有「清除搜索并定位」、素材不在项目里时给明确结论且清掉中转提示)
This commit is contained in:
2026-09-14 18:20:50 +08:00
parent cdc0e3d163
commit 2f47540eeb
2 changed files with 305 additions and 8 deletions
@@ -1577,6 +1577,17 @@ export default function ProjectDevelopmentView({
const resourceAssetGenerationPanelTaskIdRef = useRef<string | null>(null);
const [resourceAssetGenerationTasksPanelOpen, setResourceAssetGenerationTasksPanelOpen] =
useState(false);
/**
*
*
* effect`resolveResourceFocusIntent`
* effect intent
*
*/
const [resourceAssetGenerationFocusRequest, setResourceAssetGenerationFocusRequest] =
useState(0);
/** 提示条文案的 ref 版:有界兜底要判断此刻是否还停在中转文案上。 */
const resourceWorkbenchNoticeRef = useRef('');
const [resourceBottomToolbarUploading, setResourceBottomToolbarUploading] =
useState(false);
const [resourcePanelNotice, setResourcePanelNotice] = useState('');
@@ -5561,6 +5572,9 @@ export default function ProjectDevelopmentView({
}
if (focusedCommitIdsRef.current.has(intent.commitId)) {
pendingResourceFocusRef.current = null;
// 这条资源已经聚焦过了:把中转提示一并收掉,否则「生成资源已保存,正在同步资源与布局…」
// 这类文字会永久留在提示条上。
setResourceWorkbenchNotice('');
return;
}
intent.completed = true;
@@ -5578,6 +5592,9 @@ export default function ProjectDevelopmentView({
manifest.projectId,
projectPath,
resources,
// 手动「定位到素材」不改画布任何状态,靠这个序号把「这次定位请求」变成真实的依赖变化;
// 少了它 effect 不会重跑,intent 永远没人消费。
resourceAssetGenerationFocusRequest,
selectResourceCanvasPage,
typeLayout.layout.positions,
typeLayout.settled,
@@ -6671,13 +6688,20 @@ export default function ProjectDevelopmentView({
projectId: manifest.projectId,
hasIconSpecReference,
onManifestChange,
manifest,
resources,
activePageCategory,
});
resourceAssetGenerationContextRef.current = {
projectPath,
projectId: manifest.projectId,
hasIconSpecReference,
onManifestChange,
manifest,
resources,
activePageCategory,
};
resourceWorkbenchNoticeRef.current = resourceWorkbenchNotice;
const replaceResourceAssetGenerationTask = useCallback(
(next: ResourceCanvasAssetGenerationTask) => {
@@ -6908,32 +6932,91 @@ export default function ProjectDevelopmentView({
};
}, [manifest.projectId, projectPath]);
/** 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 */
/**
*
*
* **** / / /
* intent
*
* 1.
* intent
* 2. intent `resourceAssetGenerationFocusRequest` effect
* effect
*
* 3. 3
*/
const focusResourceAssetGenerationTask = useCallback(
(task: ResourceCanvasAssetGenerationTask) => {
if (!task.assetId) {
const assetId = task.assetId;
if (!assetId) {
return;
}
const context = resourceAssetGenerationContextRef.current;
const resourceId = `asset:${assetId}`;
const target = context.resources.find(
(resource) => resource.id === resourceId,
);
const locateNotice = '正在定位生成的素材…';
if (!target) {
// 不在投影里:还没同步到画布,或者素材已经不在项目里。两种都当场给结论,
// 不放 intent 也不留中转提示。
pendingResourceFocusRef.current = null;
setResourceWorkbenchNotice(
(context.manifest.assets ?? []).some((asset) => asset.id === assetId)
? '素材已登记但尚未同步到画布,请稍候重试'
: '素材已不在项目里(可能已被删除)',
);
return;
}
// 手动定位不能复用自动落卡那条 commitId`focusedCommitIdsRef` 会把同一个 commitId 记为
// 「已聚焦」,重复点同一条任务就会静默失效,所以这里用一次一点击的 flowId。
const flowId = `asset-generation-focus:${task.taskId}:${Date.now()}`;
activeFocusFlowIdRef.current = flowId;
pendingResourceFocusRef.current = {
flowId,
saveAttemptId: task.assetId,
sessionId: task.assetId,
draftId: task.assetId,
saveAttemptId: assetId,
sessionId: assetId,
draftId: assetId,
commitId: flowId,
projectPath: context.projectPath,
projectId: context.projectId,
focusGeneration: focusGenerationRef.current,
resourceId: `asset:${task.assetId}`,
resourceId,
completed: false,
};
setResourceWorkbenchNotice('正在定位生成的素材…');
if (target.category !== context.activePageCategory) {
// 素材在别的栏目:先切过去(切栏目本身就是 effect 的依赖变化),再让聚焦链在那边定位。
selectResourceCanvasPage(target.category);
}
setResourceWorkbenchNotice(locateNotice);
setResourceAssetGenerationFocusRequest((current) => current + 1);
window.setTimeout(() => {
if (focusedCommitIdsRef.current.has(flowId)) {
// 真的聚焦过了。
return;
}
const pending = pendingResourceFocusRef.current;
if (pending?.flowId === flowId) {
if (resourceWorkbenchNoticeRef.current !== locateNotice) {
// 聚焦链已经给出别的结论(例如「被当前搜索条件隐藏」+「清除搜索并定位」)。
return;
}
pendingResourceFocusRef.current = null;
setResourceWorkbenchNotice(
'未能定位到素材:画布可能仍在布局或素材暂不可见,请稍后重试',
);
return;
}
if (resourceWorkbenchNoticeRef.current === '') {
// intent 被判 invalid(项目 / 画布已切换)时聚焦链会清掉 intent 与提示:手动点击
// 不能静默丢弃,给一条能解释「为什么没动」的结论。
setResourceWorkbenchNotice(
'定位请求已失效(项目或画布已切换),请重新点击定位',
);
}
}, 3_000);
},
[],
[selectResourceCanvasPage],
);
/** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */
@@ -10898,6 +10898,220 @@ export function registerProjectAgentStatusTests() {
).not.toBeNull();
}, 20_000);
/**
* character +
*
* `openCategory`
*/
async function renderGenerationLocateView(input: {
projectId: string;
projectPath: string;
openCategory: string;
assetId: string;
ledgerRecords: Record<string, unknown>[];
}) {
const manifest = createGameCreationAppManifest(
input.projectId,
'生成任务定位测试',
);
manifest.assets = [
{
id: input.assetId,
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/locate-target.png',
source: { kind: 'canvas', resourceId: 'locate-target-resource' },
},
];
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: input.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'list_local_project_asset_generations') {
return input.ledgerRecords;
}
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'get_local_game_manifest') {
return manifest;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: input.projectPath,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory(input.openCategory);
fireEvent.click(screen.getByRole('button', { name: '生成任务' }));
return screen.findByRole('region', { name: '生成任务' });
}
function completedGenerationRecord(input: {
taskId: string;
assetId: string | null;
projectId: string;
}) {
return {
taskId: input.taskId,
projectId: input.projectId,
kind: 'character',
assetName: '定位目标素材',
status: 'completed',
phaseDetail: '生成已完成。',
createdAtMillis: 1,
startedAtMillis: 1,
finishedAtMillis: 2,
assetId: input.assetId,
error: null,
};
}
it('locates a generated asset that lives in another column instead of leaving the notice pending', async () => {
const panel = await renderGenerationLocateView({
projectId: 'workbench-locate-other-column',
projectPath: '/tmp/workbench-locate-other-column',
// 停在 UI 交互栏目:目标素材在 character 栏目。
openCategory: 'UI 交互',
assetId: 'locate-other-column-asset',
ledgerRecords: [
completedGenerationRecord({
taskId: 'task-locate-other-column',
assetId: 'locate-other-column-asset',
projectId: 'workbench-locate-other-column',
}),
],
});
fireEvent.click(
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
);
// 悬而未决的中转提示必须消失,并且真的切到目标素材所在栏目。
await waitFor(() =>
expect(screen.queryByText('正在定位生成的素材…')).toBeNull(),
);
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-book-scene-titlebar.is-active[data-resource-book-category="character"]',
),
).not.toBeNull(),
);
}, 20_000);
it('focuses a generated asset that is already in the current column', async () => {
// 这条是「点了没反应」的最小复现:不切栏目、不搜索,画布状态一个都不变,
// 只靠聚焦请求序号让 effect 重跑。
const panel = await renderGenerationLocateView({
projectId: 'workbench-locate-same-column',
projectPath: '/tmp/workbench-locate-same-column',
openCategory: '角色与对象',
assetId: 'locate-same-column-asset',
ledgerRecords: [
completedGenerationRecord({
taskId: 'task-locate-same-column',
assetId: 'locate-same-column-asset',
projectId: 'workbench-locate-same-column',
}),
],
});
fireEvent.click(
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
);
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-card-select[data-resource-id="asset:locate-same-column-asset"][aria-pressed="true"]',
),
).not.toBeNull(),
);
expect(screen.queryByText('正在定位生成的素材…')).toBeNull();
}, 20_000);
it('surfaces the existing clear-search action when the generated asset is filtered out', async () => {
const panel = await renderGenerationLocateView({
projectId: 'workbench-locate-hidden',
projectPath: '/tmp/workbench-locate-hidden',
openCategory: '角色与对象',
assetId: 'locate-hidden-asset',
ledgerRecords: [
completedGenerationRecord({
taskId: 'task-locate-hidden',
assetId: 'locate-hidden-asset',
projectId: 'workbench-locate-hidden',
}),
],
});
// 用搜索条件把目标素材挡掉:筛选面板的关键词就是画布唯一的搜索入口。
fireEvent.keyDown(window, { key: 'f', ctrlKey: true });
fireEvent.change(screen.getByLabelText('查找素材'), {
target: { value: 'zzz-no-such-resource' },
});
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.click(
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
);
await waitFor(() =>
expect(screen.queryByText('正在定位生成的素材…')).toBeNull(),
);
expect(screen.getByRole('button', { name: '清除搜索并定位' })).not.toBeNull();
}, 20_000);
it('settles a locate request whose asset is not in the project at all', async () => {
const panel = await renderGenerationLocateView({
projectId: 'workbench-locate-missing',
projectPath: '/tmp/workbench-locate-missing',
openCategory: '角色与对象',
assetId: 'locate-missing-asset',
ledgerRecords: [
completedGenerationRecord({
taskId: 'task-locate-missing',
assetId: 'asset-that-no-longer-exists',
projectId: 'workbench-locate-missing',
}),
],
});
fireEvent.click(
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
);
expect(
await screen.findByText('素材已不在项目里(可能已被删除)'),
).not.toBeNull();
expect(screen.queryByText('正在定位生成的素材…')).toBeNull();
}, 20_000);
it('routes the audio column entries to the existing audio generation chain', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-audio',