AGC 资源画布:新素材不再自动重排,改为显式「整理画布」并在生成后自动聚焦新素材

- useProjectResourceCanvasLayout:资源写意图新增 rederive 标记,显式整理那一笔按 rederive
  策略重算自动坐标(手动坐标原样保留),自动同步仍是 preserve 只补新卡
- useProjectResourceCanvasLayout:新增 rederiveNow(),复用既有写队列 / sidecar 写回链路,
  只在坐标确有变化时落盘,成功复用既有「布局已保存」提示
- index.tsx:两个排序模式的 rederiveAutomaticPositions 都固定 false,画布不再因为资源
  协调签名变化(新增素材、改标签、改分类、拓扑重建)整体重排
- index.tsx:依赖侧「关系图首次就绪」改为按项目作用域的一次性 flag,等该侧 sidecar ready
  后调用一次 rederiveNow(),之后一律只补新卡(切排序 tab 不重新武装)
- index.tsx:依赖/类型 tab 组新增「整理画布」按钮,作为整张画布重排的唯一显式入口
- index.tsx:新增 manifest.assets 新增 id 检测(seenManifestAssetIdsRef),把新卡交给既有
  pendingResourceFocusRef + advanceFocusGeneration() 裁决链聚焦;首次打开 / 切项目只登记
  基线不聚焦,重命名不改 id 天然不触发,已有同目标意图时不重复挂
- 新增 tests/resourceCanvasManualLayout.test.tsx(8 条):新素材入库后既有自动坐标逐值不变、
  依赖侧首次就绪后不再重排、新素材自动聚焦、被搜索挡住走既有「清除搜索并定位」、
  「整理画布」重算并保留手动坐标、已经整齐时再按一次不写盘、首次打开与切项目不聚焦
This commit is contained in:
2026-09-14 17:07:55 +08:00
parent 11beb65c36
commit d02d0ed7a4
3 changed files with 1015 additions and 6 deletions
@@ -2369,7 +2369,8 @@ export default function ProjectDevelopmentView({
topology: resourceLayoutTopology,
initializationReady: resourceGraphInitializationReady,
renderFallbackWhileBlocked: resourceGraphFailed,
rederiveAutomaticPositions: resourceGraphReady,
// 画布不再自动重派生:新增素材只补位,整张重排只能由「整理画布」显式发起。
rederiveAutomaticPositions: false,
});
const typeLayout = useProjectResourceCanvasLayout({
projectPath,
@@ -2377,8 +2378,38 @@ export default function ProjectDevelopmentView({
mode: 'type',
resources: resourcesWithCanvasCardSize,
initializationReady: true,
rederiveAutomaticPositions: true,
rederiveAutomaticPositions: false,
});
/**
* sidecar
*
* `rederiveAutomaticPositions` `resourceGraphReady`
*
*
* ref "这一次已经做过"
* `dependencyLayout.ready` "关系图先就绪、sidecar 后读完"
* tab
*/
const dependencyRederiveScopeRef = useRef<string | null>(null);
const dependencyLayoutReady = dependencyLayout.ready;
const rederiveDependencyLayout = dependencyLayout.rederiveNow;
useEffect(() => {
if (!resourceGraphReady || !dependencyLayoutReady) {
return;
}
const scopeKey = JSON.stringify([projectPath, manifest.projectId]);
if (dependencyRederiveScopeRef.current === scopeKey) {
return;
}
dependencyRederiveScopeRef.current = scopeKey;
rederiveDependencyLayout();
}, [
dependencyLayoutReady,
manifest.projectId,
projectPath,
rederiveDependencyLayout,
resourceGraphReady,
]);
const activeResourceLayout =
sortMode === 'dependency' ? dependencyLayout : typeLayout;
const resourceLayout = activeResourceLayout.layout;
@@ -5103,6 +5134,72 @@ export default function ProjectDevelopmentView({
[advanceFocusGeneration, manifest.assets, openUiDesignEditor],
);
/**
* `manifest.assets` **** id
*
* "刚生成" / 线
* id
*
* `pendingResourceFocusRef` +
* `advanceFocusGeneration()` DOM
* 沿
*
* /
*
*/
const seenManifestAssetIdsRef = useRef<{
scopeKey: string;
ids: Set<string>;
} | null>(null);
useEffect(() => {
const scopeKey = JSON.stringify([projectPath, manifest.projectId]);
const assetIds = manifest.assets.map((asset) => asset.id);
const previous = seenManifestAssetIdsRef.current;
if (!previous || previous.scopeKey !== scopeKey) {
seenManifestAssetIdsRef.current = { scopeKey, ids: new Set(assetIds) };
return;
}
const addedIds = assetIds.filter((id) => !previous.ids.has(id));
for (const id of assetIds) {
previous.ids.add(id);
}
if (addedIds.length === 0) {
return;
}
// 一次可能进多条(例如一次派生产出多个产物):聚焦清单里最后落地的那一条。
const addedAssetId = addedIds[addedIds.length - 1]!;
const resourceId = `asset:${addedAssetId}`;
// 资源投影是同步的:这里查不到对应资源说明这条素材不会出现在画布上,
// 不为它挂聚焦意图,免得裁决链一直停在"等待投影"。
if (!resources.some((resource) => resource.id === resourceId)) {
return;
}
if (pendingResourceFocusRef.current?.resourceId === resourceId) {
return;
}
const flowId = crypto.randomUUID();
const focusGeneration = advanceFocusGeneration();
activeFocusFlowIdRef.current = flowId;
pendingResourceFocusRef.current = {
flowId,
saveAttemptId: flowId,
sessionId: flowId,
draftId: flowId,
commitId: `asset-added:${addedAssetId}`,
projectPath,
projectId: manifest.projectId,
focusGeneration,
resourceId,
completed: false,
};
}, [
advanceFocusGeneration,
manifest.assets,
manifest.projectId,
projectPath,
resources,
]);
useEffect(() => {
if (uiEditorRoute) return;
const completed = manifest.assets.find(
@@ -7101,6 +7198,21 @@ export default function ProjectDevelopmentView({
<ListFilter size={15} aria-hidden="true" />
</button>
{/*
/
/
*/}
<button
type="button"
aria-label="整理画布"
disabled={!resourceLayoutReady}
aria-busy={resourceLayoutSaving}
onClick={() => activeResourceLayout.rederiveNow()}
>
<LayoutGrid size={15} aria-hidden="true" />
</button>
</div>
</>
) : null}
@@ -67,6 +67,15 @@ type ResourceLayoutWriteIntent = {
scopeEpoch: number;
resourceSignature: string;
conflictRetries: number;
/**
* 显式「整理画布」写意图:这一笔写回按 `rederive` 策略丢掉全部自动坐标重算。
*
* 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生
* 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能
* 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`,
* 只补新卡、不动既有坐标。
*/
rederive: boolean;
};
type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
@@ -74,9 +83,10 @@ type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2;
/**
* 自动坐标策略。`rederive` 在每次协调时丢弃全部自动坐标按当前资源与拓扑重算
* PRD 要求的「关系图首次就绪 / `dependencyDepth` / 拓扑身份签名变化后按最终拓扑
* 重算」依赖它;`preserve` 只补新资源 ID,不重排任何已存在的坐标
* 自动坐标策略。`rederive` 丢掉全部自动坐标按当前资源与拓扑重算(手动坐标原样保留);
* 它现在只由显式动作触发——用户的「整理画布」按钮,以及「关系图首次就绪」那一次
* `rederiveNow()`。`preserve` 只补新资源 ID,不重排任何已存在的坐标,是画布默认口径:
* 新增一张素材不再牵动整张画布。
*/
type AutomaticPositionPolicy = 'rederive' | 'preserve';
@@ -532,6 +542,7 @@ export function useProjectResourceCanvasLayout({
scopeEpoch,
resourceSignature: signature,
conflictRetries,
rederive: false,
});
}
if (mountedRef.current) {
@@ -565,7 +576,9 @@ export function useProjectResourceCanvasLayout({
const writePolicy =
intent.kind === 'manual'
? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY
: automaticPositionPolicy(rederiveAutomaticPositions);
: intent.rederive
? 'rederive'
: automaticPositionPolicy(rederiveAutomaticPositions);
const reconciled = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
@@ -686,6 +699,9 @@ export function useProjectResourceCanvasLayout({
if (intent.kind === 'manual') {
redragRequiredScopeEpochRef.current = null;
setNotice('布局已保存');
} else if (intent.rederive) {
// 显式整理复用同一条提示:用户按了按钮,就必须看到"这次重算真的落盘了"。
setNotice('布局已保存');
}
if (needsResourceSync) {
enqueueResourceSyncRef.current(currentScope.epoch);
@@ -1004,6 +1020,48 @@ export function useProjectResourceCanvasLayout({
[applyLayout, initializationReady, scopeKey],
);
/**
* 显式「整理画布」:丢掉全部自动坐标、按当前资源与拓扑重算一次;坐标确有变化时写回
* sidecar(沿用既有 `changed` 门),画布本身先乐观按重算结果显示。
*
* 这是整张画布重排的唯一入口——画布不再因为资源协调签名变化自动重派生。走的是与自动
* 同步同一条写队列与写回链路,只把策略换成 `rederive`;用户可见反馈仍由既有的
* `notice` / `saving` 状态位承担,不另造一套状态。
*/
const rederiveNow = useCallback(() => {
const scope = scopeRef.current;
if (
scope.key !== scopeKey ||
!initializationReady ||
initializedScopeEpochRef.current !== scope.epoch
) {
return;
}
const queued = writeQueueRef.current.find(
(intent): intent is ResourceLayoutWriteIntent =>
intent.kind === 'resources' &&
intent.scopeEpoch === scope.epoch &&
intent.rederive &&
intent !== activeWriteIntentRef.current,
);
if (queued) {
queued.resourceSignature = resourceSignatureRef.current;
queued.conflictRetries = 0;
} else {
writeQueueRef.current.push({
kind: 'resources',
scopeEpoch: scope.epoch,
resourceSignature: resourceSignatureRef.current,
conflictRetries: 0,
rederive: true,
});
}
// 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。
rebuildOptimisticLayout(scope.epoch, 'rederive');
setSaving(true);
pumpWritesRef.current();
}, [initializationReady, rebuildOptimisticLayout, scopeKey]);
const scopeMatches =
initializationReady &&
scopeRef.current.key === scopeKey &&
@@ -1035,5 +1093,6 @@ export function useProjectResourceCanvasLayout({
readReport,
scopeIdentity: scopeKey,
commitPosition,
rederiveNow,
};
}
File diff suppressed because it is too large Load Diff