合并最新 master 到 M0 分支

冲突解决:

- provider_request_builders:M0A-2 的固定 owner 收束边界与 master 新增的
  动态目标验收图改在同一段 prompt 构建。保留 owner 分支(固定 owner 仍不
  广告 project.verify / command.run_limited),把 master 的动态目标分支提到
  owner 判断之外对两条路径生效(固定 owner 属 goal_contract_participant,
  本就该收到禁止调用 agent.goal_contract 的边界声明),git 提交协议改为仅
  非 owner 路径追加以维持 M0A-2 边界。工具裁剪三个块并列保留,双方新增
  测试全部保留。
- SupervisorChatOnlyView:保留 M0B-2 的谱系分支,采纳 master 的
  projectWorkspaceStatusForDisplay 包装,避免该 import 变成死引用。
- runtime_actions:两侧各加一个导出,取并集。
- main_loop_tests:保留 M0B-1 需要的项目预初始化与 runtime lane。
- decision-log / pitfalls:双方条目都保留,各自维持原序。

验证:cargo check --all-targets 通过;前端 typecheck 干净;
provider_request_builders / game_chat 委派 / goal-control 共 17 条 Rust
用例通过;agentRuntimeModel 与 appSurface 共 391 条前端用例通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 03:48:19 +00:00
187 changed files with 41477 additions and 4987 deletions
+38 -1
View File
@@ -252,6 +252,7 @@ import {
type ProjectAgentResultSummary,
type ProjectAgentRuntimeSummary,
} from './view/project-development';
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
const initialSupervisorMessageClaimsByPage = new WeakMap<Window, Set<string>>();
const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY =
@@ -497,6 +498,7 @@ type AppProps = {
onManifestChange?: (
projectPath: string,
manifest: GameCreationAppManifest,
metadata?: ProjectManifestSnapshotMetadata,
) => void;
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
onAgentRuntimeSummariesChange?: (
@@ -10699,7 +10701,41 @@ export function App({
if (!projectSupervisorOnly || !nextProjectPath || !onManifestChange) {
return;
}
onManifestChange(nextProjectPath, manifest);
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
let cancelled = false;
void (async () => {
for (let attempt = 0; attempt < 2; attempt += 1) {
const before = await invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: nextProjectPath },
);
const currentManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
const after = await invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: nextProjectPath },
);
if (before.revision !== after.revision) {
continue;
}
if (!cancelled) {
onManifestChange(nextProjectPath, currentManifest, {
projectId: currentManifest.projectId,
revision: after.revision,
source: 'supervisor',
});
}
return;
}
})().catch(() => undefined);
return () => {
cancelled = true;
};
}, [
localProject?.projectPath,
manifest,
@@ -11266,6 +11302,7 @@ export function App({
{runtimeConfigOpen ? (
<RuntimeConfigDialog
projectPath={localProject?.projectPath}
showDeveloperEditorApi={devMode}
onClose={() => setRuntimeConfigOpen(false)}
onLog={(entry) => setCommandLog((current) => [...current, entry])}
/>
@@ -57,6 +57,7 @@ export type LauncherProjectContext = {
projectName: string;
projectKind: LocalProjectKind;
manifest: GameCreationAppManifest;
projectRevision: number | null;
mode: HomeAgentMode | null;
initialPrompt: string;
attachments: LauncherImportedAttachment[];
@@ -917,6 +917,9 @@ export async function submitProjectSupervisorRuntimeTask({
export function agentRuntimeSteerStatus(result: AgentRuntimeSteerResult) {
const runId = result.runtime.state.runId;
if (result.status === 'restarted') {
return `目标已变化,正在新 Run 重新理解并执行:${runId}`;
}
if (result.providerInterrupted) {
return `LLM 已判定需要改向,旧 Provider 已安全中断:${runId}`;
}
@@ -1256,6 +1259,18 @@ export function projectNameFromPath(projectPath: string) {
);
}
export function projectWorkspaceStatusForDisplay(workspaceStatus: string) {
const openedPrefix = '已打开:';
if (!workspaceStatus.startsWith(openedPrefix)) {
return workspaceStatus;
}
const projectPath = workspaceStatus.slice(openedPrefix.length).trim();
if (!/[\\/]/u.test(projectPath)) {
return workspaceStatus;
}
return `${openedPrefix}${projectNameFromPath(projectPath)}`;
}
export function mergeProjectSupervisorConversation(
projectRecords: LocalConversationMessageRecord[],
supervisorRecords: LocalConversationMessageRecord[],
@@ -1,10 +1,19 @@
import { Fragment, useCallback, useState } from 'react';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { launcherNotifications } from '../../app/constants';
import { closeDialogOnEscape } from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalGameProjectRevisionStatus } from '../../app/types';
import HomeView from '../../view/home';
import { type LauncherView, Sidebar } from '../../view/layout';
import ProjectDevelopmentView from '../../view/project-development';
import {
createProjectManifestMergeState,
mergeProjectManifestSnapshot,
type ProjectManifestMergeState,
type ProjectManifestSnapshot,
type ProjectManifestSnapshotMetadata,
} from '../../view/project-development/projectResourceLiveUpdateModel';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import {
@@ -56,23 +65,127 @@ export function WorkspaceLauncherShell({
createHomeDraft,
openProject,
} = homeProject;
const activeProjectContextRef = useRef(currentProjectContext);
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
activeProjectContextRef.current = currentProjectContext;
useEffect(() => {
const current = activeProjectContextRef.current;
manifestMergeRef.current =
current?.projectRevision === null || !current
? null
: createProjectManifestMergeState({
projectPath: current.projectPath,
projectId: current.manifest.projectId,
revision: current.projectRevision,
manifest: current.manifest,
source: 'initial',
});
}, [currentProjectContext?.createdAt, currentProjectContext?.projectPath]);
const applyManifestSnapshot = useCallback(
(snapshot: ProjectManifestSnapshot) => {
const current = activeProjectContextRef.current;
if (
!current ||
current.projectPath !== snapshot.projectPath ||
current.manifest.projectId !== snapshot.projectId
) {
return;
}
let previous = manifestMergeRef.current;
if (
!previous ||
previous.projectPath !== current.projectPath ||
previous.projectId !== current.manifest.projectId
) {
previous =
current.projectRevision === null
? null
: createProjectManifestMergeState({
projectPath: current.projectPath,
projectId: current.manifest.projectId,
revision: current.projectRevision,
manifest: current.manifest,
source: 'initial',
});
}
if (!previous) {
manifestMergeRef.current = createProjectManifestMergeState(snapshot);
} else {
const merged = mergeProjectManifestSnapshot(previous, snapshot);
manifestMergeRef.current = merged.state;
if (merged.decision !== 'accepted') {
return;
}
}
setCurrentProjectContext((active) =>
active &&
active.projectPath === snapshot.projectPath &&
active.manifest.projectId === snapshot.projectId
? {
...active,
manifest: snapshot.manifest,
projectRevision: snapshot.revision,
}
: active,
);
},
[setCurrentProjectContext],
);
const syncActiveProjectManifest = useCallback(
(
sourceProjectPath: string,
manifest: NonNullable<typeof currentProjectContext>['manifest'],
metadata?: ProjectManifestSnapshotMetadata,
) => {
setCurrentProjectContext((current) => {
if (
!current ||
current.projectPath !== sourceProjectPath ||
current.manifest === manifest
) {
return current;
const current = activeProjectContextRef.current;
if (!current || current.projectPath !== sourceProjectPath) {
return;
}
if (metadata) {
applyManifestSnapshot({
projectPath: sourceProjectPath,
manifest,
...metadata,
});
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
if (current.projectRevision === null) {
setCurrentProjectContext((active) =>
active?.projectPath === sourceProjectPath
? { ...active, manifest }
: active,
);
}
return { ...current, manifest };
});
return;
}
void invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: sourceProjectPath },
)
.then((status) => {
applyManifestSnapshot({
projectPath: sourceProjectPath,
projectId: manifest.projectId,
revision: status.revision,
manifest,
source: 'supervisor',
});
})
.catch(() => {
if (activeProjectContextRef.current?.projectRevision === null) {
setCurrentProjectContext((active) =>
active?.projectPath === sourceProjectPath
? { ...active, manifest }
: active,
);
}
});
},
[setCurrentProjectContext],
[applyManifestSnapshot, setCurrentProjectContext],
);
function showLauncherNotice(title: string) {
@@ -175,6 +288,7 @@ export function WorkspaceLauncherShell({
preview={activeProjectPreview}
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
agentResults={activeProjectAgentResults}
onManifestChange={syncActiveProjectManifest}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
supervisor={
@@ -17,6 +17,7 @@ import type {
ProjectAgentResultSummary,
ProjectAgentRuntimeSummary,
} from '../../view/project-development';
import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel';
import { isAbsoluteProjectPath } from '../project-summary/projectSummary';
const RECENT_WORKSPACES_STORAGE_KEY =
@@ -38,6 +39,7 @@ export type ProjectSupervisorComponentProps = {
onManifestChange?: (
projectPath: string,
manifest: GameCreationAppManifest,
metadata?: ProjectManifestSnapshotMetadata,
) => void;
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
onAgentRuntimeSummariesChange?: (
@@ -16,6 +16,7 @@ import type {
InitLocalProjectResult,
LauncherImportedAttachment,
LauncherProjectContext,
LocalGameProjectRevisionStatus,
LocalProjectDirectoryStatus,
PendingNonEmptyProject,
TauriInvoke,
@@ -96,6 +97,23 @@ export function useHomeProjectCreation({
rememberRecentWorkspace(context.projectPath);
}
async function readCurrentProjectRevision(
invoke: TauriInvoke,
nextProjectPath: string,
) {
try {
const status = await invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: nextProjectPath },
);
return Number.isSafeInteger(status.revision) && status.revision >= 0
? status.revision
: null;
} catch {
return null;
}
}
async function importHomeAttachments(
invoke: TauriInvoke,
nextProjectPath: string,
@@ -213,6 +231,10 @@ export function useHomeProjectCreation({
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
mode,
initialPrompt:
prompt.trim() ||
@@ -277,6 +299,10 @@ export function useHomeProjectCreation({
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
mode: null,
initialPrompt: '',
attachments: [],
@@ -338,6 +364,10 @@ export function useHomeProjectCreation({
projectNameFromPath(trimmedProjectPath),
projectKind: directoryStatus.isGodotProject ? 'godot' : 'web',
manifest: projectManifest,
projectRevision: await readCurrentProjectRevision(
invoke,
trimmedProjectPath,
),
mode: null,
initialPrompt: '',
attachments: [],
@@ -481,6 +511,10 @@ export function useHomeProjectCreation({
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'godot',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
mode: null,
initialPrompt: '',
attachments: [],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,469 @@
.asset-canvas-surface {
--image-canvas-brand-accent: var(--platform-button-primary-solid);
--image-canvas-brand-accent-strong: var(--platform-text-strong);
--image-canvas-brand-border-soft: var(--platform-icon-border);
--image-canvas-brand-border-strong: var(--platform-nav-active-border);
--image-canvas-brand-focus-ring: var(--platform-input-focus-ring);
--image-canvas-brand-soft: var(--platform-nav-active-fill);
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: 100%;
height: 100%;
min-height: 0;
container-type: inline-size;
overflow: hidden;
border-radius: inherit;
background: var(--platform-overlay-fill);
color: var(--platform-text-strong);
}
.asset-canvas-surface--loading {
place-items: center;
}
.asset-canvas-surface__toolbar-shell {
z-index: 4;
display: flex;
flex-direction: column;
min-width: 0;
align-items: stretch;
gap: 8px;
padding: 8px 10px 9px;
border-bottom: 1px solid var(--platform-line-soft);
background: var(--platform-desktop-topbar-fill);
}
.asset-canvas-surface__toolbar {
width: 100%;
max-width: 100%;
flex: 0 0 auto;
padding-block: 1px 3px;
}
.asset-canvas-surface__savebar {
display: grid;
width: 100%;
max-width: 620px;
grid-template-columns:
minmax(150px, 1.35fr) minmax(118px, 1fr) minmax(88px, 0.55fr)
max-content;
flex: 0 0 auto;
min-width: 0;
align-self: flex-end;
align-items: end;
gap: 8px;
margin-inline-start: auto;
}
.asset-canvas-surface__save-field {
display: grid;
min-width: 0;
gap: 3px;
}
.asset-canvas-surface__save-field > span {
color: var(--platform-text-muted);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.asset-canvas-surface__savebar input,
.asset-canvas-surface__savebar select,
.asset-canvas-surface__generation-form select {
width: 100%;
min-width: 0;
min-height: 34px;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-input-fill);
color: var(--platform-text-base);
font: inherit;
padding: 0 8px;
}
.asset-canvas-surface__savebar input,
.asset-canvas-surface__savebar select {
font-size: 12px;
font-weight: 600;
line-height: 1.25;
}
.asset-canvas-surface__savebar input:focus-visible,
.asset-canvas-surface__savebar select:focus-visible,
.asset-canvas-surface__generation-form select:focus-visible,
.asset-canvas-surface__generation-form textarea:focus-visible {
border-color: var(--platform-surface-hover-border);
outline: none;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.asset-canvas-surface__import-input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.asset-canvas-surface .asset-canvas-surface__primary-action,
.asset-canvas-surface .asset-canvas-surface__save {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
box-shadow: var(--platform-nav-active-shadow);
}
.asset-canvas-surface .asset-canvas-surface__save {
min-width: max-content;
min-height: 34px;
align-self: end;
white-space: nowrap;
}
.asset-canvas-surface
.asset-canvas-surface__primary-action:not(:disabled):hover,
.asset-canvas-surface .asset-canvas-surface__save:not(:disabled):hover {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.asset-canvas-surface[data-state='canvas.generating']
.asset-canvas-surface__viewport,
.asset-canvas-surface[data-state='canvas.generating']
.asset-canvas-surface__viewport-tools {
pointer-events: none;
}
.asset-canvas-surface__dialog-backdrop {
position: absolute;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: color-mix(
in srgb,
var(--platform-text-strong) 36%,
transparent
);
backdrop-filter: blur(5px);
}
.asset-canvas-surface__generation-dialog {
display: grid;
width: min(560px, 100%);
max-height: calc(100% - 32px);
min-height: 0;
overflow: auto;
border: 1px solid var(--platform-modal-border);
border-radius: 16px;
background: var(--platform-modal-fill);
color: var(--platform-text-strong);
box-shadow: var(--platform-panel-shadow);
}
.asset-canvas-surface__generation-dialog > header,
.asset-canvas-surface__generation-dialog footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
}
.asset-canvas-surface__generation-dialog > header {
border-bottom: 1px solid var(--platform-line-soft);
}
.asset-canvas-surface__generation-form,
.asset-canvas-surface__generation-confirmation {
display: grid;
gap: 14px;
padding: 16px;
}
.asset-canvas-surface__generation-form label {
display: grid;
gap: 6px;
color: var(--platform-text-base);
font-size: 13px;
font-weight: 700;
}
.asset-canvas-surface__generation-form textarea {
box-sizing: border-box;
width: 100%;
resize: vertical;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
padding: 10px;
background: var(--platform-input-fill);
color: var(--platform-text-strong);
font: inherit;
}
.asset-canvas-surface__generation-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.asset-canvas-surface__generation-form fieldset {
display: grid;
max-height: 180px;
gap: 8px;
overflow: auto;
border: 1px solid var(--platform-subpanel-border);
border-radius: 10px;
padding: 10px;
}
.asset-canvas-surface__generation-form fieldset label {
display: flex;
align-items: center;
font-weight: 500;
}
.asset-canvas-surface__generation-form fieldset input {
min-height: auto;
}
.asset-canvas-surface__generation-dialog footer {
justify-content: flex-end;
padding: 0;
}
.asset-canvas-surface__viewport {
grid-row: 2;
min-height: 0;
background-color: var(--platform-neutral-bg);
background-image: radial-gradient(
var(--platform-line-soft) 1px,
transparent 1px
);
background-size: 18px 18px;
}
.asset-canvas-surface__operation-overlay {
z-index: 12;
grid-row: 2;
grid-column: 1;
display: grid;
min-width: 0;
min-height: 0;
place-items: center;
overflow: auto;
padding: 20px;
background: color-mix(
in srgb,
var(--platform-text-strong) 30%,
transparent
);
backdrop-filter: blur(4px);
}
.asset-canvas-surface__operation-card {
display: grid;
width: min(440px, 100%);
max-height: 100%;
min-height: 0;
gap: 14px;
overflow: auto;
border: 1px solid var(--platform-modal-border);
border-radius: 18px;
padding: 22px;
background: var(--platform-modal-fill);
color: var(--platform-text-strong);
box-shadow: var(--platform-panel-shadow);
}
.asset-canvas-surface__operation-card strong {
font-size: 20px;
}
.asset-canvas-surface__operation-card p {
margin: 0;
color: var(--platform-text-base);
line-height: 1.55;
overflow-wrap: anywhere;
}
.asset-canvas-surface__operation-card progress {
width: 100%;
height: 8px;
overflow: hidden;
border: 0;
border-radius: 999px;
accent-color: var(--platform-accent);
}
.asset-canvas-surface__operation-kicker {
color: var(--platform-warm-text);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.asset-canvas-surface__operation-card--failed {
border-color: var(--platform-button-danger-border);
}
.asset-canvas-surface__operation-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.asset-canvas-surface__resize-handle {
position: absolute;
right: -8px;
bottom: -8px;
width: 16px;
height: 16px;
border: 2px solid var(--platform-input-fill);
border-radius: 50%;
background: var(--platform-accent);
cursor: nwse-resize;
}
.asset-canvas-surface__layer-hit-target {
display: block;
width: 100%;
height: 100%;
padding: 0;
overflow: hidden;
border: 0;
background: transparent;
color: inherit;
cursor: inherit;
}
.asset-canvas-surface__layer-hit-target:focus-visible,
.asset-canvas-surface__resize-handle:focus-visible {
outline: 3px solid var(--platform-focus-ring);
outline-offset: 3px;
}
.asset-canvas-surface__viewport-tools {
position: absolute;
z-index: 5;
right: 14px;
bottom: 46px;
display: grid;
gap: 8px;
}
.asset-canvas-surface__zoom {
max-width: min(100%, 280px);
justify-content: flex-end;
}
.asset-canvas-surface .genarrative-image-canvas__minimap {
width: 132px;
height: 84px;
padding: 0;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
background: var(--platform-input-fill);
box-shadow: var(--platform-nav-active-shadow);
}
.asset-canvas-surface .genarrative-image-canvas__minimap-layer {
background: var(--platform-warm-bg);
}
.asset-canvas-surface .genarrative-image-canvas__minimap-viewport {
border: 1px solid var(--platform-accent);
}
.asset-canvas-surface__status {
z-index: 4;
display: flex;
min-height: 34px;
max-height: 96px;
align-items: center;
flex-wrap: wrap;
gap: 12px;
overflow-y: auto;
padding: 8px 12px;
border-top: 1px solid var(--platform-line-soft);
background: var(--platform-desktop-topbar-fill);
color: var(--platform-text-base);
font-size: 12px;
line-height: 1.4;
scrollbar-gutter: stable;
}
.asset-canvas-surface__status > span {
min-width: 0;
overflow-wrap: anywhere;
}
.asset-canvas-surface__status > span:first-child {
color: var(--platform-text-strong);
font-weight: 800;
}
@media (max-width: 760px) {
.asset-canvas-surface {
height: 100%;
min-height: 0;
border-radius: 0;
}
.asset-canvas-surface__toolbar-shell {
padding-inline: 8px;
}
.asset-canvas-surface__savebar {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.asset-canvas-surface__save-field:first-child {
grid-column: 1 / -1;
}
.asset-canvas-surface__save {
grid-column: 1 / -1;
width: 100%;
}
.asset-canvas-surface__viewport-tools {
right: 8px;
bottom: 52px;
}
.asset-canvas-surface__generation-options {
grid-template-columns: 1fr;
}
.asset-canvas-surface__operation-overlay {
place-items: end stretch;
padding: 10px;
}
.asset-canvas-surface__operation-card {
width: auto;
border-radius: 16px;
padding: 18px;
}
}
@container (max-width: 620px) {
.asset-canvas-surface__savebar {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.asset-canvas-surface__save-field:first-child,
.asset-canvas-surface__save {
grid-column: 1 / -1;
width: 100%;
}
}
@@ -0,0 +1,16 @@
export type {
CanvasLayer,
CanvasViewport,
ImageCanvasAssetPort,
ImageCanvasCompletionPort,
ImageCanvasGenerationPort,
ImageCanvasProjectPort,
} from '@genarrative/image-canvas-core';
export {
CanvasViewport as SharedCanvasViewport,
CanvasWorld as SharedCanvasWorld,
LayerRenderer as SharedLayerRenderer,
Minimap as SharedMinimap,
SelectionOverlay as SharedSelectionOverlay,
ZoomControls as SharedZoomControls,
} from '@genarrative/image-canvas-react';
@@ -12,7 +12,10 @@ import type {
ChatMessage,
PendingUiConfirmation,
} from '../../app/types';
import { ProjectSupervisorRuntimePanel } from '../agent-runtime';
import {
ProjectSupervisorRuntimePanel,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
import { taskStatusLabels } from '../project-summary/projectSummary';
@@ -127,7 +130,7 @@ export function ProjectSupervisorView({
</button>
</form>
<small className="project-supervisor-workspace-status">
{workspaceStatus}
{projectWorkspaceStatusForDisplay(workspaceStatus)}
</small>
</div>
<aside
@@ -32,6 +32,7 @@ import {
formatAgentRuntimeTaskQueue,
ProjectSupervisorRuntimePanel,
projectSupervisorVisibleConversationText,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import {
formatAgentCardLlmStatus,
@@ -283,15 +284,12 @@ export function ProjectWorkspaceChatPane({
<div className="chat-header-title">
<h1>AI 游戏创作</h1>
<span>{devMode ? '开发模式' : currentProjectTitle}</span>
{!devMode && localProject ? (
<small>{localProject.projectPath}</small>
) : null}
<small>run: {agentRunStatus}</small>
<small>preview: {previewStatus}</small>
{mainProjectSummary ? (
<small aria-label="项目摘要">{mainProjectSummary}</small>
) : null}
<small>{workspaceStatus}</small>
<small>{projectWorkspaceStatusForDisplay(workspaceStatus)}</small>
</div>
<div className="chat-header-actions">
<button type="button" onClick={showChatHelp}>
@@ -41,6 +41,7 @@ import {
projectSupervisorCollaboratingAgentRuntimes,
ProjectSupervisorRuntimeControls,
projectSupervisorVisibleConversationText,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { GameChatImageViewer } from './GameChatImageViewer';
@@ -1000,7 +1001,7 @@ export function SupervisorChatOnlyView({
? '专业 Agent 正在收束'
: projectedRuntime
? projectSupervisorChatRuntimeStatus(projectedRuntime)
: workspaceStatus);
: projectWorkspaceStatusForDisplay(workspaceStatus));
const headerStatus = gameChatMode ? status : previewStatus || status;
const runtimeEvents = useMemo(
() => collectGameChatRuntimeEvents(projectedRuntime, runtimeByAgentId),
@@ -522,10 +522,12 @@ function normalizeRuntimeConfigDraft(
export function RuntimeConfigDialog({
projectPath,
showDeveloperEditorApi = false,
onClose,
onLog,
}: {
projectPath?: string;
showDeveloperEditorApi?: boolean;
onClose: () => void;
onLog?: (entry: string) => void;
}) {
@@ -1379,28 +1381,38 @@ export function RuntimeConfigDialog({
})}
</>
) : null}
<label>
画板 API Base URL
<input
aria-label="画板 API Base URL"
value={runtimeConfigDraft.editorApi.baseUrl}
onChange={(event) =>
updateRuntimeEditorConfig('baseUrl', event.currentTarget.value)
}
/>
</label>
<label>
画板 API Key
<input
aria-label="画板 API Key"
autoComplete="off"
type="password"
value={runtimeConfigDraft.editorApi.apiKey}
onChange={(event) =>
updateRuntimeEditorConfig('apiKey', event.currentTarget.value)
}
/>
</label>
{showDeveloperEditorApi ? (
<>
<label>
开发者 External Editor Base URL
<input
aria-label="开发者 External Editor Base URL"
value={runtimeConfigDraft.editorApi.baseUrl}
onChange={(event) =>
updateRuntimeEditorConfig(
'baseUrl',
event.currentTarget.value,
)
}
/>
</label>
<label>
开发者 External Editor API Key
<input
aria-label="开发者 External Editor API Key"
autoComplete="off"
type="password"
value={runtimeConfigDraft.editorApi.apiKey}
onChange={(event) =>
updateRuntimeEditorConfig(
'apiKey',
event.currentTarget.value,
)
}
/>
</label>
</>
) : null}
<section className="runtime-mcp-section" aria-label="MCP servers">
<header className="runtime-mcp-header">
<div>
+2 -2
View File
@@ -1,3 +1,4 @@
import '@genarrative/image-canvas-react/styles.css';
import './styles.css';
import React from 'react';
@@ -12,8 +13,7 @@ import {
} from './App';
const initialSearchParams = new URLSearchParams(window.location.search);
const gameChatReleaseMode =
import.meta.env.VITE_AGC_GAME_CHAT_ONLY === 'true';
const gameChatReleaseMode = import.meta.env.VITE_AGC_GAME_CHAT_ONLY === 'true';
const supervisorChatMode =
import.meta.env.DEV && initialSearchParams.has('supervisor-chat');
const gameChatMode =
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
import { ChevronLeft, LoaderCircle, Sparkles } from 'lucide-react';
import { type FormEvent, useState } from 'react';
import {
type LocalProjectResourceEditKind,
resourceEditPromptMaxLength,
} from './resourceEditModel';
export type ResourceEditSubmitInput = {
prompt: string;
assetName: string;
};
export function ResourceEditSurface({
resourceLabel,
editKind,
initialAssetName,
semanticNotice,
onCancel,
onSubmit,
}: {
resourceLabel: string;
editKind: LocalProjectResourceEditKind;
initialAssetName: string;
semanticNotice: string | null;
onCancel: () => void;
onSubmit: (input: ResourceEditSubmitInput) => Promise<void>;
}) {
const [prompt, setPrompt] = useState('');
const [assetName, setAssetName] = useState(initialAssetName);
const [attempted, setAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const inputLocked = attempted || submitting;
const promptMaxLength = resourceEditPromptMaxLength(editKind);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedPrompt = prompt.trim();
const normalizedName = assetName.trim();
if (!normalizedPrompt || !normalizedName || submitting) {
return;
}
setAttempted(true);
setSubmitting(true);
setError('');
try {
await onSubmit({ prompt: normalizedPrompt, assetName: normalizedName });
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: String(submitError),
);
setSubmitting(false);
}
}
return (
<section
className="game-resource-editor"
aria-labelledby="game-resource-editor-title"
>
<header className="game-resource-editor-titlebar">
<button
type="button"
className="game-resource-editor-back"
onClick={onCancel}
disabled={submitting}
>
<ChevronLeft size={16} aria-hidden="true" />
返回资源
</button>
<span>
<small>编辑现有资源</small>
<strong id="game-resource-editor-title">{resourceLabel}</strong>
</span>
</header>
<form className="game-resource-editor-form" onSubmit={submit}>
{editKind !== 'version' ? (
<label>
<span>派生资源名称</span>
<input
value={assetName}
maxLength={120}
disabled={inputLocked}
onChange={(event) => setAssetName(event.currentTarget.value)}
/>
</label>
) : null}
<label>
<span>编辑提示词</span>
<textarea
value={prompt}
maxLength={promptMaxLength}
rows={7}
autoFocus
disabled={inputLocked}
placeholder="描述希望在现有资源基础上发生的变化"
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
</label>
{semanticNotice ? (
<p className="game-resource-editor-semantic-note">{semanticNotice}</p>
) : null}
{error ? (
<p className="game-resource-editor-error" role="alert">
{error}
</p>
) : null}
<div className="game-resource-editor-actions">
{error ? (
<button type="submit" disabled={submitting}>
<Sparkles size={15} aria-hidden="true" />
使用原请求重试
</button>
) : (
<button
type="submit"
disabled={submitting || !prompt.trim() || !assetName.trim()}
>
{submitting ? (
<LoaderCircle
className="game-resource-editor-spinner"
size={15}
aria-hidden="true"
/>
) : (
<Sparkles size={15} aria-hidden="true" />
)}
{submitting
? '正在派生…'
: editKind === 'version'
? '创建子版本'
: '生成派生资源'}
</button>
)}
</div>
</form>
</section>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ProjectManifestSnapshotSource =
| 'initial'
| 'supervisor'
| 'asset-command'
| 'asset-event'
| 'poll';
export type ProjectManifestSnapshot = {
projectPath: string;
projectId: string;
revision: number;
manifest: GameCreationAppManifest;
source: ProjectManifestSnapshotSource;
commitId?: string;
eventId?: string;
};
export type ProjectManifestSnapshotMetadata = Omit<
ProjectManifestSnapshot,
'projectPath' | 'manifest'
>;
export type ProjectManifestMergeState = {
projectPath: string;
projectId: string;
revision: number;
manifestFingerprint: string;
seenCommitRevisions: string[];
seenEventIds: string[];
};
export type ProjectManifestMergeDecision =
| 'accepted'
| 'duplicate'
| 'stale-revision'
| 'scope-mismatch'
| 'revision-conflict';
const MAX_DEDUPE_IDENTITIES = 256;
function appendBounded(values: string[], value: string | undefined) {
if (!value || values.includes(value)) {
return values;
}
return [...values, value].slice(-MAX_DEDUPE_IDENTITIES);
}
function manifestFingerprint(manifest: GameCreationAppManifest) {
return JSON.stringify(manifest);
}
function commitRevisionIdentity(snapshot: ProjectManifestSnapshot) {
return snapshot.commitId
? `${snapshot.projectId}:${snapshot.commitId}:${snapshot.revision}`
: undefined;
}
function withDedupeIdentities(
state: ProjectManifestMergeState,
snapshot: ProjectManifestSnapshot,
) {
return {
...state,
seenCommitRevisions: appendBounded(
state.seenCommitRevisions,
commitRevisionIdentity(snapshot),
),
seenEventIds: appendBounded(state.seenEventIds, snapshot.eventId),
};
}
export function createProjectManifestMergeState(
snapshot: ProjectManifestSnapshot,
): ProjectManifestMergeState {
return withDedupeIdentities(
{
projectPath: snapshot.projectPath,
projectId: snapshot.projectId,
revision: snapshot.revision,
manifestFingerprint: manifestFingerprint(snapshot.manifest),
seenCommitRevisions: [],
seenEventIds: [],
},
snapshot,
);
}
export function mergeProjectManifestSnapshot(
current: ProjectManifestMergeState,
snapshot: ProjectManifestSnapshot,
): {
decision: ProjectManifestMergeDecision;
state: ProjectManifestMergeState;
} {
if (
snapshot.projectPath !== current.projectPath ||
snapshot.projectId !== current.projectId ||
snapshot.manifest.projectId !== snapshot.projectId
) {
return { decision: 'scope-mismatch', state: current };
}
if (snapshot.revision < current.revision) {
return { decision: 'stale-revision', state: current };
}
const fingerprint = manifestFingerprint(snapshot.manifest);
if (snapshot.revision === current.revision) {
if (fingerprint !== current.manifestFingerprint) {
return { decision: 'revision-conflict', state: current };
}
return {
decision: 'duplicate',
state: withDedupeIdentities(current, snapshot),
};
}
return {
decision: 'accepted',
state: withDedupeIdentities(
{
...current,
revision: snapshot.revision,
manifestFingerprint: fingerprint,
},
snapshot,
),
};
}
export type ResourceFocusIntent = {
flowId: string;
saveAttemptId: string;
sessionId: string;
draftId: string;
commitId: string;
projectPath: string;
projectId: string;
focusGeneration: number;
resourceId: string | null;
completed: boolean;
};
export type ResourceFocusResolution =
| 'invalid'
| 'wait-projection'
| 'wait-layout'
| 'hidden'
| 'wait-dom'
| 'focus'
| 'completed';
export function resolveResourceFocusIntent(
intent: ResourceFocusIntent,
input: {
projectPath: string;
projectId: string;
flowId: string | null;
focusGeneration: number;
projected: boolean;
dependencyLayoutSettled: boolean;
dependencyPositioned: boolean;
typeLayoutSettled: boolean;
typePositioned: boolean;
visible: boolean;
domRendered: boolean;
},
): ResourceFocusResolution {
if (intent.completed) {
return 'completed';
}
if (
intent.projectPath !== input.projectPath ||
intent.projectId !== input.projectId ||
intent.flowId !== input.flowId ||
intent.focusGeneration !== input.focusGeneration
) {
return 'invalid';
}
if (!intent.resourceId || !input.projected) {
return 'wait-projection';
}
if (
!input.dependencyLayoutSettled ||
!input.typeLayoutSettled ||
!input.dependencyPositioned ||
!input.typePositioned
) {
return 'wait-layout';
}
if (!input.visible) {
return 'hidden';
}
if (!input.domRendered) {
return 'wait-dom';
}
return 'focus';
}
@@ -0,0 +1,216 @@
import type { ProjectResource } from './resourceProjectionModel';
export type LocalProjectResourceEditKind =
| 'image-reference'
| 'svg'
| 'video'
| 'sound-effect'
| 'background-music'
| 'text'
| 'agent-result'
| 'version';
export type ProjectResourceEditCapability =
| {
route: 'image-canvas';
sourceAssetId: string;
normalizeSource: false;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
}
| {
route: 'image-canvas';
sourceAssetId: null;
normalizeSource: true;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
}
| {
route: 'derive';
editKind: LocalProjectResourceEditKind;
sourceMediaType: string;
semanticNotice: string | null;
};
const extensionMediaTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
svg: 'image/svg+xml',
avif: 'image/avif',
bmp: 'image/bmp',
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
opus: 'audio/opus',
md: 'text/markdown',
markdown: 'text/markdown',
mdx: 'application/mdx',
txt: 'text/plain',
json: 'application/json',
yaml: 'application/yaml',
yml: 'application/yaml',
toml: 'application/toml',
html: 'text/html',
htm: 'text/html',
css: 'text/css',
scss: 'text/css',
less: 'text/css',
js: 'text/javascript',
jsx: 'text/javascript',
mjs: 'text/javascript',
cjs: 'text/javascript',
ts: 'text/typescript',
tsx: 'text/typescript',
rs: 'text/x-rust',
py: 'text/x-python',
go: 'text/x-go',
java: 'text/plain',
kt: 'text/plain',
kts: 'text/plain',
c: 'text/plain',
cc: 'text/plain',
cpp: 'text/plain',
h: 'text/plain',
hpp: 'text/plain',
cs: 'text/plain',
swift: 'text/plain',
php: 'text/plain',
rb: 'text/plain',
lua: 'text/plain',
sh: 'text/plain',
bash: 'text/plain',
zsh: 'text/plain',
sql: 'text/plain',
graphql: 'text/plain',
gql: 'text/plain',
xml: 'text/plain',
csv: 'text/csv',
ini: 'text/plain',
conf: 'text/plain',
vue: 'text/plain',
svelte: 'text/plain',
};
function pathExtension(path: string) {
return path.split(/[?#]/u, 1)[0]?.split('.').pop()?.toLowerCase() ?? '';
}
export function canonicalProjectedResourceMediaType(resource: ProjectResource) {
const declared = resource.mediaType.trim().toLowerCase();
if (/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/u.test(declared)) {
return declared;
}
return extensionMediaTypes[pathExtension(resource.path)] ?? declared;
}
function isBackgroundMusic(resource: ProjectResource) {
const semanticSource = `${resource.subtype} ${resource.path}`.toLowerCase();
return ['background', 'bgm', 'music', 'theme'].some((marker) =>
semanticSource.includes(marker),
);
}
export function resolveProjectResourceEditCapability(
resource: ProjectResource,
): ProjectResourceEditCapability {
if (resource.version) {
return {
route: 'derive',
editKind: 'version',
sourceMediaType: 'application/vnd.genarrative.project-version+json',
semanticNotice: null,
};
}
if (resource.subtype === 'agent-result') {
return {
route: 'derive',
editKind: 'agent-result',
sourceMediaType: 'text/markdown',
semanticNotice: null,
};
}
const mediaType = canonicalProjectedResourceMediaType(resource);
if (['image/png', 'image/jpeg', 'image/webp'].includes(mediaType)) {
return resource.manifestAssetId
? {
route: 'image-canvas',
sourceAssetId: resource.manifestAssetId,
normalizeSource: false,
mediaType: mediaType as 'image/png' | 'image/jpeg' | 'image/webp',
}
: {
route: 'image-canvas',
sourceAssetId: null,
normalizeSource: true,
mediaType: mediaType as 'image/png' | 'image/jpeg' | 'image/webp',
};
}
if (mediaType === 'image/svg+xml') {
return {
route: 'derive',
editKind: 'svg',
sourceMediaType: mediaType,
semanticNotice: null,
};
}
if (mediaType.startsWith('image/')) {
return {
route: 'derive',
editKind: 'image-reference',
sourceMediaType: mediaType,
semanticNotice: null,
};
}
if (mediaType.startsWith('video/')) {
return {
route: 'derive',
editKind: 'video',
sourceMediaType: mediaType,
semanticNotice: null,
};
}
if (resource.category === 'audio' || mediaType.startsWith('audio/')) {
return {
route: 'derive',
editKind: isBackgroundMusic(resource)
? 'background-music'
: 'sound-effect',
sourceMediaType: mediaType,
semanticNotice: '音频将基于原资源语义派生重制,不会修改原音频波形。',
};
}
return {
route: 'derive',
editKind: 'text',
sourceMediaType: mediaType,
semanticNotice: null,
};
}
export function defaultDerivedResourceName(resource: ProjectResource) {
const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
return `${baseName || '资源'}-编辑版`;
}
export function resourceEditPromptMaxLength(
editKind: LocalProjectResourceEditKind,
) {
switch (editKind) {
case 'background-music':
return 140;
case 'sound-effect':
return 1_900;
case 'video':
return 4_000;
default:
return 32_000;
}
}
@@ -47,7 +47,8 @@ export type ProjectResource = {
version?: ProjectVersionResourceSummary;
};
const documentExtension = /\.(md|markdown|mdx|txt|json|ya?ml|toml)$/iu;
const documentExtension =
/\.(md|markdown|mdx|txt|json|ya?ml|toml|html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|xml|csv|ini|conf|vue|svelte)$/iu;
const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu;
const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu;
const artKind =
@@ -120,7 +121,7 @@ export function projectResourcesFromReadModels(
}
for (const path of task.artifacts) {
const category = classifyProjectedResource({ path, mediaType: '' });
if (!category || category === 'audio') {
if (!category) {
continue;
}
resources.push({
@@ -28,6 +28,7 @@ type LayoutScope = {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resourceSignature: string;
};
type ManualLayoutWriteIntent = {
@@ -54,8 +55,9 @@ function createScopeKey(
projectPath: string,
projectId: string,
mode: ProjectResourceCanvasLayoutMode,
resourceSignature: string,
) {
return JSON.stringify([projectPath, projectId, mode]);
return JSON.stringify([projectPath, projectId, mode, resourceSignature]);
}
export function createResourceSignature(resources: ResourceCanvasItem[]) {
@@ -125,6 +127,7 @@ export function useProjectResourceCanvasLayout({
mode,
resources,
initializationReady = true,
renderFallbackWhileBlocked = false,
rederiveAutomaticPositions = false,
}: {
projectPath: string;
@@ -132,29 +135,36 @@ export function useProjectResourceCanvasLayout({
mode: ProjectResourceCanvasLayoutMode;
resources: ResourceCanvasItem[];
initializationReady?: boolean;
renderFallbackWhileBlocked?: boolean;
rederiveAutomaticPositions?: boolean;
}) {
const scopeKey = createScopeKey(projectPath, projectId, mode);
const resourceSignature = useMemo(
() => createResourceSignature(resources),
[resources],
);
const fallback = useMemo(
() => {
const empty = createEmptyResourceCanvasLayout(projectId, mode);
return initializationReady
? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout
: empty;
}, [
initializationReady,
mode,
projectId,
rederiveAutomaticPositions,
resources,
]);
const scopeKey = createScopeKey(
projectPath,
projectId,
mode,
resourceSignature,
);
const fallback = useMemo(() => {
const empty = createEmptyResourceCanvasLayout(projectId, mode);
return initializationReady || renderFallbackWhileBlocked
? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout
: empty;
}, [
initializationReady,
mode,
projectId,
rederiveAutomaticPositions,
renderFallbackWhileBlocked,
resources,
]);
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
const [notice, setNotice] = useState<LayoutNotice>('');
const [saving, setSaving] = useState(false);
const [readyScopeKey, setReadyScopeKey] = useState<string | null>(null);
const mountedRef = useRef(true);
const layoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const persistedLayoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
@@ -167,6 +177,7 @@ export function useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resourceSignature,
});
const writeQueueRef = useRef<LayoutWriteIntent[]>([]);
const activeWriteIntentRef = useRef<LayoutWriteIntent | null>(null);
@@ -383,8 +394,7 @@ export function useProjectResourceCanvasLayout({
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
)
.changed
).changed
) {
enqueueResourceSyncRef.current(currentScope.epoch);
}
@@ -483,12 +493,14 @@ export function useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resourceSignature,
};
scopeRef.current = scope;
initializedScopeEpochRef.current = null;
writeQueueRef.current = [];
activeWriteIntentRef.current = null;
redragRequiredScopeEpochRef.current = null;
setReadyScopeKey(null);
const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode);
if (!initializationReady) {
persistedLayoutRef.current = emptyLayout;
@@ -510,6 +522,7 @@ export function useProjectResourceCanvasLayout({
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
initializedScopeEpochRef.current = epoch;
setReadyScopeKey(scopeKey);
pumpWritesRef.current();
return undefined;
}
@@ -531,6 +544,7 @@ export function useProjectResourceCanvasLayout({
}
persistedLayoutRef.current = loaded;
initializedScopeEpochRef.current = epoch;
setReadyScopeKey(scopeKey);
if (
reconcileLayout(
loaded,
@@ -549,6 +563,7 @@ export function useProjectResourceCanvasLayout({
}
persistedLayoutRef.current = initialFallback;
initializedScopeEpochRef.current = epoch;
setReadyScopeKey(scopeKey);
rebuildOptimisticLayout(epoch);
setNotice('布局读取失败,已使用当前会话布局');
pumpWritesRef.current();
@@ -564,6 +579,7 @@ export function useProjectResourceCanvasLayout({
projectPath,
rebuildOptimisticLayout,
rederiveAutomaticPositions,
resourceSignature,
scopeKey,
]);
@@ -670,11 +686,30 @@ export function useProjectResourceCanvasLayout({
scopeRef.current.key === scopeKey &&
layout.projectId === projectId &&
layout.mode === mode;
const ready = scopeMatches && readyScopeKey === scopeKey;
const allResourcesPositioned = resources.every((resource) =>
layout.positions.some(
(position) =>
position.resourceId === resource.id &&
position.section === resource.category,
),
);
const settled =
ready &&
!saving &&
activeWriteIntentRef.current === null &&
!writeQueueRef.current.some(
(intent) => intent.scopeEpoch === scopeRef.current.epoch,
) &&
allResourcesPositioned;
return {
layout: scopeMatches ? layout : fallback,
notice,
saving,
ready,
settled,
scopeIdentity: scopeKey,
commitPosition,
};
}