diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7be35aff2..4d28f041a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -283,7 +283,9 @@ import type { import { chatComposerDraftToDirectCodexUserItem, RESOURCE_REFERENCE_INSERT_EVENT, + RESOURCE_REFERENCE_INSERT_MANY_EVENT, type ResourceReferenceInsertEventDetail, + type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; @@ -3633,15 +3635,39 @@ export function App({ chatComposerRef.current?.insertReferences([detail.reference]); chatComposerRef.current?.focus(); }; + /** + * 画布拖拽的批量 @ 引用:与单条入口同一个消费点,只是一次把整批插进去。 + * + * 逐条派发单条事件也能跑,但每次都会重建一次草稿并重新聚焦,而且插入顺序只能靠事件顺序兜着; + * 批次为空时静默返回(不开一次空事务)。 + */ + const handleResourceReferenceInsertMany = (event: Event) => { + const detail = ( + event as CustomEvent + ).detail; + const references = detail?.references ?? []; + if (references.length === 0) return; + chatComposerRef.current?.insertReferences(references); + chatComposerRef.current?.focus(); + }; window.addEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); - return () => + window.addEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + return () => { window.removeEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); + window.removeEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + }; }, []); function prepareProjectAssetRegisterDraft(localPath: string) { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 938e65e3c..a150971df 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -74,6 +74,31 @@ export function dispatchResourceReferenceInsert(reference: ChatReference) { ); } +/** + * 一次插入多条引用的事件(画布拖拽批量 @ 引用用)。 + * + * 与单条事件同一条链路、同一个消费点,只是把「N 条」合成一次派发:逐条派发会让草稿重建 + * N 次、聚焦 N 次,而且插入顺序只能靠事件顺序兜着。批次为空时什么都不派发(不开一次空事务)。 + */ +export const RESOURCE_REFERENCE_INSERT_MANY_EVENT = + 'agc-resource-reference-insert-many'; + +export type ResourceReferenceInsertManyEventDetail = { + references: ChatReference[]; +}; + +export function dispatchResourceReferenceInsertMany( + references: ChatReference[], +) { + if (typeof window === 'undefined' || references.length === 0) return; + window.dispatchEvent( + new CustomEvent( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + { detail: { references } }, + ), + ); +} + /** * `@` 输入区 portal 到 `document.body` 的两块浮层:资源选择器与输入区候选菜单。 * diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx index 0a6cc43ff..539131016 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx @@ -43,6 +43,13 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = { onToggleOpen: () => void; /** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */ onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void; + /** + * 锚点落在哪个工作面:资源栏目画布 / 运行表现层 / UI 编辑器。 + * + * 三处右上角的既有 chrome 不同(运行表现层右上角是版本入口、UI 编辑器是整页编辑器壳), + * 所以坐标由这一档在样式里分档给出;缺省按资源栏目画布算。 + */ + placement?: 'canvas' | 'run' | 'editor'; }; /** @@ -222,14 +229,16 @@ function resourceCanvasGenerationTaskRowNode( /** * 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。 * - * 形态对齐网页端美术画布的任务侧栏:贴边的独立 ` + ) : null} + ); } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css index 56abc9d1c..0605445d1 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css @@ -1,21 +1,89 @@ /* 「生成任务」侧栏的样式。 * * 分层与网页端美术画布的任务侧栏(`ImageCanvasTaskSidebarView.tsx` + `src/index.css:6084+`)一一对应: - * 容器 → 头部(标题 + 在途计数)→ 分栏标题(各带条数)→ 条目卡片(状态徽标 / 阶段 / 耗时 / 定位)。 - * 差别只有一处:**颜色全部换成 AGC 的平台设计 token(`--platform-*`)**,不再照抄网页端的固定色值, + * 右上角锚点(收起态是开关、展开态是浮动面板,同一时刻只有其一)→ 头部(标题 + 在途计数)→ + * 分栏标题(各带条数)→ + * 条目卡片(状态徽标 / 阶段 / 耗时 / 定位)。 + * + * 位置与开合形态照抄美术画布:**画布右上角那枚开关是收起态的入口,展开后开关让位、只留面板** + * (两者不同框 —— 初版把它们并排摆过,验收现场直接判为不对)。差别只有一处: + * **颜色与外形全部走 AGC 的平台设计 token(`--platform-*`)**,不再照抄网页端的固定色值, * 这样亮/暗主题都跟着 `platform-theme` 走,也不引入第二套色板。 * * 行为(分栏、条数、已完成封顶、折叠不清列表、提交后自动展开)都不在这里,样式只负责表现。 */ -.game-resource-generation-tasks-sidebar { - position: fixed; - top: 4rem; - bottom: 6rem; - left: 0.75rem; +/* + * 右上角锚点:开关与面板同挂在这里,所以「收起只剩开关、展开只剩面板」这两个状态共用同一处 + * 坐标,不会出现开关与面板各锚一处、开合时互相漂移。 + * + * 锚点是 stage 网格里**与工作面同一个单元格**的另一个条目(`grid-row` 按 `placement` 分档 + + * `justify-self: end` 贴右 + `align-self: start` 贴顶),**不用 `position: absolute`**: + * 绝对定位要写死一个 `top`,而工具条(stage 第一行)的高度会随按钮换行变化 —— 那个写死的 + * 偏移量迟早会压在工具条上(验收现场那条「生成任务和菜单栏重叠」就是这么来的)。 + * 放进同一个网格单元格以后,「画布顶边 + 一小段内缩」由网格自己保证,工具条多高都不用管。 + * + * 分档:资源栏目画布与 UI 编辑器在第二 / 第三行,运行表现层占 `2 / -1` 且它右上角 + * (`top: 20px; right: 20px`)被版本入口占着,所以那一档把内缩上边距加大到版本入口之下。 + */ +.game-resource-generation-tasks-anchor { + grid-row: 3; + grid-column: 1; + justify-self: end; + align-self: start; + margin: 0.85rem; z-index: 40; + display: flex; + align-items: flex-start; + gap: 0.45rem; + pointer-events: auto; +} + +.game-resource-generation-tasks-anchor[data-generation-tasks-placement='run'] { + grid-row: 2 / -1; + margin: 3.5rem 1.1rem 0.85rem 0.85rem; +} + +.game-resource-generation-tasks-anchor[data-generation-tasks-placement='editor'] { + grid-row: 2; +} + +/* + * 右上角开关:外观沿用原工具条那一枚「生成任务」按钮(次级胶囊:描边 + secondary 填充 + + * 12px/700),搬到画布上多一档面板投影,保证压在画布素材上仍然看得清。 + */ +.game-resource-generation-tasks-toggle { + display: inline-flex; + min-height: 30px; + align-items: center; + gap: 5px; + padding: 0 11px; + border: 1px solid var(--platform-surface-border); + border-radius: 999px; + background: var(--platform-button-secondary-fill); + box-shadow: var(--platform-panel-shadow); + color: var(--platform-button-secondary-text); + cursor: pointer; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.game-resource-generation-tasks-toggle:hover { + border-color: var(--platform-accent); + color: var(--platform-text-strong); +} + +.game-resource-generation-tasks-toggle:focus-visible { + outline: 2px solid var(--platform-accent); + outline-offset: 2px; + box-shadow: 0 0 0 3px var(--platform-input-focus-ring); +} + +.game-resource-generation-tasks-sidebar { display: flex; width: min(300px, 80vw); + max-height: min(30rem, calc(100vh - 12rem)); flex-direction: column; overflow: hidden; border: 1px solid var(--platform-subpanel-border); @@ -27,11 +95,12 @@ animation: game-resource-generation-tasks-enter 160ms ease-out; } -/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。 */ +/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。 + * 进场方向跟着锚点走:面板从右上角往左展开,所以位移取正 X。 */ @keyframes game-resource-generation-tasks-enter { from { opacity: 0; - transform: translateX(-0.5rem); + transform: translateX(0.5rem); } to { @@ -54,7 +123,7 @@ to { opacity: 0; - transform: translateX(-0.5rem); + transform: translateX(0.5rem); } } @@ -356,11 +425,14 @@ /* 窄屏(含 360px):侧栏占满可用宽度,不挡画布操作。 */ @media (max-width: 480px) { + .game-resource-generation-tasks-anchor { + /* 拉满画布那一格:`justify-self: end` 只让锚点贴右,窄屏要的是整宽。 */ + justify-self: stretch; + margin: 0.5rem; + } + .game-resource-generation-tasks-sidebar { - top: 3.5rem; - right: 0.5rem; - bottom: 5.5rem; - left: 0.5rem; + max-height: calc(100vh - 12rem); width: auto; } } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 68071970e..99e0417d3 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -1212,56 +1212,47 @@ } /* - * 「点选替换」提示条:点选态下贴在资源画布顶端居中浮出。 + * 「替换素材」面板的锚点:画布右上角、任务开关那一枚的下方。 * - * 与右下角缩放控件同一层语义(都在管理区之上,`position: absolute` 锚在 - * `.game-resource-book-manager` 上,而不是挂在会随视口缩放 / 平移的场景里), - * 配色沿用工作台提示条那一套(`.game-resource-live-notice`)。 + * 面板本体(`.image-canvas-editor__project-asset-picker--floating`)是**非模态浮层**: + * 面板开着的时候画布照常可点,在画布上点目标素材即落进面板的当前选择,写入只由面板「确认」发起。 + * 所以这里只负责锚定,壳样式(边框 / 底色 / 阴影 / 头部与滚动区)在共享样式表里,宿主不抄第二份。 * - * 它是**会话级**提示:会话期间常驻,不跟随某张卡,所以层级高于场景(20)与缩放(40); - * 场景根是 `pointer-events: none`,这里必须显式收回指针事件,「取消」才点得动。 + * 位置取舍:右上角是画布上唯一不被栏目工具栏(左下)与缩放 / 撤销 Dock(右下)占用的稳定空位; + * 顶部 0.85rem 起是任务开关,面板从 3.2rem 起排(开关约 30px 高 + 一档间距),两者不重叠 —— + * 而且打开替换面板时会把「生成任务」面板收起来,同一个角只留一块浮层。 + * + * 锚在**画布容器**(`.game-resource-book-manager`,本身就是 `position: relative`)而不是 stage: + * 相对 stage 写死的偏移会在工具条换行变高时压上去(与任务开关同一类问题)。 */ -.game-resource-canvas-pick-hint { +.game-resource-replacement-panel { position: absolute; - top: 14px; - left: 50%; - z-index: 50; - display: inline-flex; - align-items: center; - gap: 10px; - max-width: min(92vw, 34rem); - padding: 8px 8px 8px 12px; - border: 1px solid #edc7b5; - border-radius: 10px; - background: rgb(255 247 241 / 96%); - box-shadow: 0 6px 18px rgb(112 70 52 / 14%); - color: #8d5b45; - font-size: 11px; - line-height: 1.4; - transform: translateX(-50%); - pointer-events: auto; + top: 3.2rem; + right: 0.85rem; + z-index: 45; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.4rem; } -.game-resource-canvas-pick-hint-text { - flex: 1 1 auto; - min-width: 0; -} - -.game-resource-canvas-pick-hint-error { - flex: 0 1 auto; - min-width: 0; - color: #b3261e; - font-weight: 700; -} - -.game-resource-canvas-pick-hint button { - flex: 0 0 auto; - border: 1px solid #dc9b7d; - border-radius: 8px; - background: #fff; - color: #9b5537; - font-size: 11px; - cursor: pointer; +/* + * 源素材那一行小字:面板改成非模态以后,画布上的「源素材」光环会被一次空白点击清掉, + * 而面板里只列候选、不说明在替换谁 —— 源身份得在面板这一侧留一份可读的说法。 + */ +.game-resource-replacement-panel-source { + max-width: 100%; + overflow: hidden; + border: 1px solid var(--platform-subpanel-border); + border-radius: 999px; + background: var(--platform-subpanel-fill); + box-shadow: var(--platform-panel-shadow); + color: var(--platform-text-base); + font-size: 0.72rem; + font-weight: 750; + padding: 0.2rem 0.6rem; + text-overflow: ellipsis; + white-space: nowrap; } /* 栏目画布底部工具栏:栏目页(view=child)左下角的画布 chrome。 diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts index 98febadae..b1508be6e 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts @@ -291,18 +291,18 @@ export function resourceVersionReplacementErrorMessage(error: unknown): string { return message; } -/** 画布点选态点到的资源卡,能不能作为这次替换的目标。 */ +/** 在画布上点到的资源卡,能不能作为这次替换的目标。 */ export type ResourceReplacementPickResolution = | { status: 'replace'; replacementResourceId: string } | { status: 'rejected'; message: string }; /** - * 「点选替换」:把画布上点到的那张卡解析成写入目标,或给出「为什么不能替换」。 + * 画布点选:把画布上点到的那张卡解析成**面板里的当前替换目标**,或给出「为什么不能替换」。 * - * 判据与候选弹窗**同源**,不重算兼容性:目标必须是 manifest 资产、必须在后端给出的权威 + * 判据与面板里的候选列表**同源**,不重算兼容性:目标必须是 manifest 资产、必须在后端给出的权威 * 候选里(源素材已被后端排除)、且硬门禁通过(`resourceReplacementBlockedReason`)。 * 拒绝文案也走同一条翻译路径(`resourceVersionReplacementErrorMessage` 认的就是 Rust 那几种 - * 错误形状),所以点选与列表两条入口对同一件事永远说同一句话。 + * 错误形状),所以画布点选与面板候选列表对同一件事永远说同一句话。 */ export function resolveResourceReplacementPick({ manifestAssetId, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 6833fd921..a33fe8eb2 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -1070,7 +1070,11 @@ textarea { padding: 10px 12px; border: 1px solid var(--platform-subpanel-border); border-radius: 12px; - background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent); + background: color-mix( + in srgb, + var(--platform-subpanel-fill) 94%, + transparent + ); box-shadow: 0 8px 24px rgb(31 24 16 / 8%); } @@ -5896,6 +5900,9 @@ iframe.preview-frame { .game-workbench-stage > .game-resource-manager { grid-row: 3; + /* 显式钉住第 1 列:右上角任务锚点是同一格里第二个网格条目,画布若走自动列放置会被挤到 + 第二列(隐式列),画布会被压窄一半。 */ + grid-column: 1; min-height: 0; } @@ -5904,13 +5911,46 @@ iframe.preview-frame { box-shadow: var(--platform-nav-active-shadow); } +/* + * 「拖动素材到对话」的落点提示:按对话栏当时的屏幕矩形铺一层虚线描边 + 一枚说明 chip。 + * + * 纯只读浮层(`pointer-events: none`):不接管命中,也不改对话栏自身的排版与定位——拖动是指针 + * 捕获的,命中判定本来就在画布那侧算,这里只负责把"松手会发生什么"说出来。 + */ +.game-workbench-chat-reference-drop { + position: fixed; + z-index: 60; + display: grid; + place-items: start center; + padding-top: 1rem; + border: 2px dashed var(--platform-accent); + border-radius: 12px; + background: color-mix(in srgb, var(--platform-accent) 8%, transparent); + pointer-events: none; +} + +.game-workbench-chat-reference-drop-chip { + display: inline-flex; + min-height: 30px; + align-items: center; + padding: 0 12px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 999px; + background: var(--platform-subpanel-fill); + box-shadow: var(--platform-panel-shadow); + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 700; +} + .game-workbench-stage[data-resource-view-state='resources.ui-editor'] { grid-template-rows: auto minmax(0, 1fr); } .game-workbench-stage[data-resource-view-state='resources.ui-editor'] - > div:last-child { + > .game-workbench-editor-shell { grid-row: 2; + grid-column: 1; min-height: 0; } @@ -7557,7 +7597,6 @@ iframe.preview-frame { padding-right: 50px; } - /* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。 卡片底部整条是卡面名称(`.game-resource-card-name`),所以血缘角标压在名称条之上; 右下角仍是媒体播放钮,最大宽度按右侧让出播放钮的宽度。 @@ -8360,6 +8399,9 @@ iframe.preview-frame { display: grid; grid-template-rows: minmax(300px, 1fr) auto; grid-row: 2 / -1; + /* 与右上角任务锚点同格(见 resourceCanvasAssetGenerationTasksSidebar.css):显式钉第 1 列, + 避免画布被自动列放置挤到隐式列里。 */ + grid-column: 1; gap: 12px; height: 100%; min-height: 0; @@ -10423,7 +10465,9 @@ button.design-workspace-tree__entry:hover, overflow: visible; } -.game-workbench-layout--design .game-workbench-chat .project-supervisor-surface { +.game-workbench-layout--design + .game-workbench-chat + .project-supervisor-surface { grid-row: 2; } @@ -10781,8 +10825,7 @@ button.design-workspace-tree__entry:hover, 这样「消息内容 / 顶栏 / 输入盒」三者共用同一个基准值,不会再出现 「消息内缩 16px、输入盒只有 10px」这种左右不齐,或不一致的上/下间距。 (非 direct-codex 的面板仍走上面那条 `padding: 10px`。) */ -.game-workbench-chat - .project-supervisor-surface.is-direct-codex { +.game-workbench-chat .project-supervisor-surface.is-direct-codex { padding: 0; } @@ -10921,7 +10964,9 @@ button.design-workspace-tree__entry:hover, 菜单跑到**整个 composer 上方**、和触发钮之间隔着整个输入区,输入区一变高(多行、 引用 chip、AI 润色)菜单与提示就跟着往上飘,看起来就是"编辑框把弹层挤开了"。 `relative` 不会把它移出控制排(`right/bottom: auto` 仍在原位),只补回锚点。 */ -.game-workbench-layout--design .project-supervisor-composer-controls .conversation-model-select, +.game-workbench-layout--design + .project-supervisor-composer-controls + .conversation-model-select, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex @@ -10957,7 +11002,9 @@ button.design-workspace-tree__entry:hover, /* 控制排三只方钮(`+` 附件 / `@` 引用 / 发送)共用一套尺寸。发送钮单独加圆角与主色, 见下面两条规则。 */ -.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button, +.game-workbench-layout--design + .project-supervisor-composer-controls + .project-supervisor-submit-button, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex @@ -11021,7 +11068,9 @@ button.design-workspace-tree__entry:hover, } /* 发送钮是圆形主色块(Codex 观感):直径与左右两只方钮同档,圆角收到 999px。 */ -.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button, +.game-workbench-layout--design + .project-supervisor-composer-controls + .project-supervisor-submit-button, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex @@ -12253,7 +12302,8 @@ button.design-workspace-tree__entry:hover, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - > * + * { + > * + + * { margin-top: 14px; } @@ -12310,14 +12360,16 @@ button.design-workspace-tree__entry:hover, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - details[data-testid='live-reasoning'] > summary { + details[data-testid='live-reasoning'] + > summary { cursor: pointer; } .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - details[data-testid='live-reasoning'] pre { + details[data-testid='live-reasoning'] + pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; @@ -12330,14 +12382,33 @@ button.design-workspace-tree__entry:hover, .project-supervisor-surface.is-direct-codex .project-supervisor-message-list .message - :where(p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, pre, code, strong, em, th, td) { + :where( + p, + h1, + h2, + h3, + h4, + h5, + h6, + ul, + ol, + li, + blockquote, + pre, + code, + strong, + em, + th, + td + ) { color: inherit; } .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message p { + .message + p { opacity: 1; } @@ -12361,19 +12432,23 @@ button.design-workspace-tree__entry:hover, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message p, + .message + p, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message ul, + .message + ul, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message ol, + .message + ol, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message li { + .message + li { line-height: 1.5 !important; } @@ -12429,7 +12504,11 @@ button.design-workspace-tree__entry:hover, .message-turn-process > summary > .agent-process-summary - :is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron) { + :is( + .agent-process-summary-icon, + .agent-process-summary-meta, + .agent-process-summary-chevron + ) { color: inherit; } @@ -12501,8 +12580,14 @@ button.design-workspace-tree__entry:hover, } /* chevron 旋转只有一处来源:折叠态不旋转,展开态 rotate(180deg)(120ms 过渡内截图会看到中间角度)。 */ -.message-turn-process > summary .agent-process-summary .agent-process-summary-chevron, -.design-agent-reasoning > summary .agent-process-summary .agent-process-summary-chevron { +.message-turn-process + > summary + .agent-process-summary + .agent-process-summary-chevron, +.design-agent-reasoning + > summary + .agent-process-summary + .agent-process-summary-chevron { transform: none; } @@ -12559,10 +12644,21 @@ details.design-agent-reasoning[open] .project-supervisor-surface.is-direct-codex .agent-tool-call-group[data-has-failure='true'] > .agent-tool-call-group-head - :is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron), + :is( + .agent-process-summary-icon, + .agent-process-summary-meta, + .agent-process-summary-chevron + ), .game-workbench-chat .project-supervisor-surface.is-direct-codex .agent-tool-call-group-row[data-status='failed'] - :is(.agent-tool-call-row-head, .agent-tool-call-row-text, .agent-tool-call-row-status, .agent-tool-call-row-icon, .agent-tool-call-row-duration, .agent-tool-call-row-chevron) { + :is( + .agent-tool-call-row-head, + .agent-tool-call-row-text, + .agent-tool-call-row-status, + .agent-tool-call-row-icon, + .agent-tool-call-row-duration, + .agent-tool-call-row-chevron + ) { color: var(--platform-button-danger-text, #a6402f); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index f0895eaf7..f956ccf58 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -30,7 +30,6 @@ import { Info, Layers, LayoutGrid, - ListChecks, ListFilter, Maximize2, Minus, @@ -118,9 +117,12 @@ import { ResourceReferenceInput } from '../../features/project-workspace/Resourc import { type ChatComposerDraft, type ChatReference, + dedupeChatReferences, dispatchResourceReferenceInsert, + dispatchResourceReferenceInsertMany, isResourceReferenceOverlayTarget, resolveActiveIterationVersion, + resourceDisplayName, resourceReferenceCategoryLabel, } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; @@ -345,6 +347,14 @@ import { projectResourcePathExtension, projectResourceStructuredPreviewText, } from './resourceCardPreviewModel'; +import { + resourceCardReferenceDropContains, + resourceCardReferenceDropHintLabel, + type ResourceCardReferenceDropRect, + resourceCardReferenceDropRect, + resourceCardReferenceDropReferences, + sameResourceCardReferenceDrop, +} from './resourceCardReferenceDropModel'; import { ResourceClassificationPanel } from './ResourceClassificationPanel'; import { EMPTY_PROJECT_RESOURCE_GRAPH, @@ -2264,13 +2274,21 @@ export default function ProjectDevelopmentView({ useState([]); const [resourceReplacementOpen, setResourceReplacementOpen] = useState(false); /** - * 「点选替换」:关掉候选弹窗(它的全屏遮罩会盖住画布),直接在资源画布上点目标素材。 + * 在画布上点选出来的替换目标(manifest 资产 id)+ 这次点选的序号。 * - * 与弹窗互斥,但**不重置** `source` / `candidates`:点选时的合法性判据( - * `resolveResourceReplacementPick`)与写入载荷用的还是这一份冻结的源身份与权威候选。 + * 替换面板是**非模态**的:面板开着的时候画布照常可点,点中的合法候选落成这里的「本次目标」, + * 面板里随之选中它;真正的写入仍然只由面板的「确认」发起。所以它既不是「点了就换」, + * 也不是第二份候选——合法性判据(`resolveResourceReplacementPick`)与写入载荷用的还是 + * 那一份冻结的源身份与权威候选。 + * + * `revision` 是给面板的「初值换了」信号:面板只在打开那一帧读 `selectedAssetIds`, + * 而数组本身每次渲染都会重建、不能当信号(见组件里的注释)。连续点同一张卡时序号照增, + * 保证每次都真的同步一次。 */ - const [resourceReplacementPickMode, setResourceReplacementPickMode] = - useState(false); + const [resourceReplacementPick, setResourceReplacementPick] = useState<{ + assetId: string; + revision: number; + } | null>(null); const [resourceReplacementLoading, setResourceReplacementLoading] = useState(false); const [resourceReplacementError, setResourceReplacementError] = useState< @@ -2366,6 +2384,24 @@ export default function ProjectDevelopmentView({ } | null>(null); const resourceDependencyOverlayRef = useRef(null); + /** + * 「拖动素材到对话」的落点:Agent 对话栏(`.game-workbench-chat`)。 + * + * 拖动是**指针捕获**的:指针跑到对话栏上时,pointermove 仍然回到卡片上,所以落点只能自己量。 + * 用 ref 拿对话栏元素(不 `document.querySelector`),对话栏在 UI 编辑器下不渲染,读不到就是 + * 没有落点。 + */ + const workbenchChatRef = useRef(null); + /** + * 正在拖到对话上时的提示(`null` = 不在落点上)。 + * + * 只活在这次拖动里:松手(无论落点在哪)与拖动取消都在同两处收干净,不会留下一个挂着的提示条。 + * `count` 是要插入的引用条数,`rect` 是对话栏当时的屏幕矩形(提示条按它铺)。 + */ + const [resourceCardReferenceDrop, setResourceCardReferenceDrop] = useState<{ + count: number; + rect: ResourceCardReferenceDropRect; + } | null>(null); const skipNextResourceCardClickRef = useRef(null); const skipNextResourceCardClickTimerRef = useRef(null); /** @@ -4180,6 +4216,8 @@ export default function ProjectDevelopmentView({ } resourceCardDragRef.current = null; setResourceCardDragPreview(null); + // 取消也要把「拖到对话」的落点提示收干净:它和拖动是同一个生命周期。 + setResourceCardReferenceDrop(null); resourceDependencyOverlayRef.current?.clearDragPreview(); clearSkippedResourceCardClick(); }, [clearSkippedResourceCardClick]); @@ -5831,14 +5869,35 @@ export default function ProjectDevelopmentView({ [], ); + /** + * 「拖到对话」的落点裁决:指针是否在 Agent 对话栏上,以及这次松手会插入几条引用。 + * + * pointermove(画提示)与 pointerup(真正派发)共用这一条判据,所以提示条上说的 + * 「松手即可 @ 引用 N 项素材」与实际插入的条数不会分成两套口径。 + */ + const resolveResourceCardReferenceDrop = useCallback( + (clientX: number, clientY: number, movedResourceIds: readonly string[]) => { + const rect = resourceCardReferenceDropRect(workbenchChatRef.current); + if (!rect || !resourceCardReferenceDropContains(rect, clientX, clientY)) { + return null; + } + return { + count: resourceCardReferenceDropReferences(movedResourceIds, resources) + .length, + rect, + }; + }, + [resources], + ); + const handleResourceCardPointerDown = useCallback( (event: ReactPointerEvent, resource: ProjectResource) => { if (event.button !== 0) { return; } - if (resourceReplacementPickMode) { - // 点选态下左键按下就是「选这个目标」:不起拖拽、不换选中,直接把这次点选交给 - // 替换链路(合法即提交,非法把原因留在提示条上)。 + if (resourceReplacementOpen) { + // 替换面板开着时左键按下就是「选这个目标」:不起拖拽、不换画布选中,直接把这次点选 + // 交给替换链路(合法即记为本次目标,非法把原因留在面板里)。 pickResourceReplacementTargetRef.current(resource); return; } @@ -5904,7 +5963,7 @@ export default function ProjectDevelopmentView({ resourceCategoryScopeKey, resourceBookOpensAllResources, resourceBookScenePlan, - resourceReplacementPickMode, + resourceReplacementOpen, selectedResourceIds, visibleResourceIds, ], @@ -5928,6 +5987,31 @@ export default function ProjectDevelopmentView({ skipNextResourceCardClickRef.current = drag.resourceId; } drag.changed = true; + /** + * 拖到对话栏上:这一刻的语义从「排版」切成「引用」。 + * + * 卡片不再跟着指针走(否则整批卡会被拖到画布外的对话栏上,像要把素材搬出项目), + * 落点提示按对话栏矩形铺开;指针移回画布时预览会在下一次 pointermove 自动恢复。 + */ + const referenceDrop = resolveResourceCardReferenceDrop( + event.clientX, + event.clientY, + drag.moves.map((move) => move.resourceId), + ); + // 只有条数或对话栏矩形真的变了才落状态:`pointermove` 每帧都算一次新对象, + // 直接把新对象塞进 state 会让整个工作台每帧重渲染一遍。 + setResourceCardReferenceDrop((current) => + current && + referenceDrop && + sameResourceCardReferenceDrop(current, referenceDrop) + ? current + : referenceDrop, + ); + if (referenceDrop) { + setResourceCardDragPreview(null); + resourceDependencyOverlayRef.current?.clearDragPreview(); + return; + } // 位移只有一份:整张选择集按同一个世界坐标位移走,渲染时每张卡各自加上它(见 // `renderResourceBookCard`),落盘时再按同一尺度换成栏目内局部坐标。 const scaledDeltaX = deltaX / drag.startScale; @@ -5946,7 +6030,7 @@ export default function ProjectDevelopmentView({ y: (primary?.startY ?? 0) + scaledDeltaY, }); }, - [], + [resolveResourceCardReferenceDrop], ); const finishResourceCardPointerDrag = useCallback( @@ -5973,6 +6057,38 @@ export default function ProjectDevelopmentView({ deferSkippedResourceCardClickCleanup(drag.resourceId); } setResourceCardDragPreview(null); + /** + * 松手落在对话栏上:这次拖动的语义是「把素材 @ 进对话」,不是排版 —— 一条坐标都不写。 + * + * 不写盘的另一个理由是它本来就写不了:卡片画的是栏目内局部坐标,拖动位移换到对话栏那一段 + * 会得到一串跑到画布外的坐标。引用按 `drag.moves` 派发(多选时整批、单选时只有按住的那张), + * 与拖动位移的选择集合同源;一条也引用不了(素材都没登记)时给原因,不静默什么都不发生。 + */ + const referenceDrop = + commit && drag.changed + ? resolveResourceCardReferenceDrop( + event.clientX, + event.clientY, + drag.moves.map((move) => move.resourceId), + ) + : null; + setResourceCardReferenceDrop(null); + if (referenceDrop) { + const references = dedupeChatReferences( + resourceCardReferenceDropReferences( + drag.moves.map((move) => move.resourceId), + resources, + ), + ); + if (references.length > 0) { + dispatchResourceReferenceInsertMany(references); + } else { + setResourceWorkbenchNotice( + '选中的素材都还没登记为项目资源,暂时不能 @ 引用', + ); + } + return; + } // 取消手势(pointercancel / 捕获丢失 / 窗口失焦):拖动期间从未改写坐标,丢掉预览即 // 回到拖动前的位置;已选中的多选保持不动,不在这里清选择。 if (!commit || !drag.changed) { @@ -6015,7 +6131,9 @@ export default function ProjectDevelopmentView({ activeResourceLayout, clearSkippedResourceCardClick, deferSkippedResourceCardClickCleanup, + resolveResourceCardReferenceDrop, resourceCategoryScopeKey, + resources, setResourceCanvasHistory, ], ); @@ -6056,16 +6174,6 @@ export default function ProjectDevelopmentView({ ) { return; } - // 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡, - // 点空白既不退出点选,也不把用户正在替换的选中清掉。中键 / 空格平移照常, - // 滚轮平移与缩放走 manager 上那条与指针无关的通路,不受影响。 - if ( - resourceReplacementPickMode && - event.button === 0 && - !resourceCanvasSpacePanRef.current - ) { - return; - } resourceBookTransitionControllerRef.current.settle(); // 右键平移,保留中键/空格抓手;空白处左键继续框选。 if (isPan) { @@ -6101,7 +6209,6 @@ export default function ProjectDevelopmentView({ activePageCategory, clearResourceCanvasFocus, resourceBookOpensAllResources, - resourceReplacementPickMode, ], ); @@ -6849,7 +6956,8 @@ export default function ProjectDevelopmentView({ * * 两处必须排除,否则会把用户刚做的动作吃掉: * - 侧栏内部:点任务卡、点「定位到素材」都不能收起侧栏; - * - 工具条上那枚开合按钮:它自己负责 toggle,若在这里也被判成「点外部」就会先收起再被 toggle + * - 右上角那枚开合开关(`data-resource-generation-task-toggle`):它自己负责 toggle,若在这里被 + * 判成「点外部」就会先收起再被 toggle * 打开,表现为按钮失灵。点它时这里的 pointerdown 直接放行,收尾交给它的 click。 * * 用 pointerdown 而不是 click:画布空白处的左键 pointerdown 会 `preventDefault()`,掐掉指针的 @@ -6919,8 +7027,11 @@ export default function ProjectDevelopmentView({ setResourceReplacementError(null); setResourceReplacementCandidates([]); setResourceReplacementSource(source); - // 从工具条重新进入口时先退出上一次的点选会话(点选态下工具条仍可点)。 - setResourceReplacementPickMode(false); + // 画布右上角只放一块浮层:替换面板与「生成任务」侧栏抢同一个角,打开替换就把任务面板收起来 + // (任务照旧在后台推进,收起只影响这个视图)。 + setResourceAssetGenerationTasksPanelOpen(false); + // 从工具条重新进入口时清掉上一次在画布上点出来的目标:面板里的选中必须与这次源素材一致。 + setResourceReplacementPick(null); setResourceReplacementLoading(true); try { const result = await readVersionResourceReplacementCandidates({ @@ -6942,29 +7053,21 @@ export default function ProjectDevelopmentView({ [activeVersionId, projectPath, projectVersions], ); /** - * 结束一次替换会话:弹窗、点选态与冻结的源身份 / 候选一起清干净。 + * 结束一次替换会话:面板、点选目标与冻结的源身份 / 候选一起清干净。 * - * 弹窗取消、点选态取消(Esc / 提示条「取消」)与替换成功都走这一处,避免出现 - * "弹窗关了但点选态还挂着"或"点选退出了但候选还留着"的半截状态。 + * 面板取消(× / Esc)、替换成功与切工作面都走这一处,避免出现「面板关了但目标还挂着」的 + * 半截状态。 */ const closeResourceVersionReplacement = useCallback(() => { setResourceReplacementOpen(false); - setResourceReplacementPickMode(false); + setResourceReplacementPick(null); setResourceReplacementSource(null); setResourceReplacementCandidates([]); setResourceReplacementError(null); - // 点选态给资源卡留的一次性点击抑制在这里收尾:不清干净的话,退出点选后下一次点 + // 点选给资源卡留的一次性点击抑制在这里收尾:不清干净的话,关掉面板后下一次点 // 同一张卡会被吞掉一次(与拖拽结束后的收尾同一口径)。 clearSkippedResourceCardClick(); }, [clearSkippedResourceCardClick]); - /** - * 进入「点选替换」:关掉候选弹窗(它的全屏遮罩会盖住画布),冻结的源身份与权威候选原样留着。 - */ - const enterResourceReplacementPickMode = useCallback(() => { - setResourceReplacementOpen(false); - setResourceReplacementError(null); - setResourceReplacementPickMode(true); - }, []); /** * 确认替换:直接改该版本的绑定(不建新版本),成功后重读 manifest。 * @@ -6975,7 +7078,10 @@ export default function ProjectDevelopmentView({ const confirmResourceVersionReplacement = useCallback( async (assetIds: string[]) => { const source = resourceReplacementSource; - const replacementResourceId = assetIds[0]; + // 点选出来的目标优先兜底:面板的当前选择来自 `initialSelectionRevision` 同步, + // 用户在点完画布那一帧立刻按确认(重渲染还没落地)时,不该给出「请选择一个替换素材」。 + const replacementResourceId = + assetIds[0] ?? resourceReplacementPick?.assetId ?? null; if (!source || !replacementResourceId) { setResourceReplacementError('请选择一个替换素材'); return; @@ -7029,6 +7135,7 @@ export default function ProjectDevelopmentView({ projectPath, reloadManifestAfterAssetCommand, resourceReplacementLoading, + resourceReplacementPick, resourceReplacementSource, ], ); @@ -7058,6 +7165,27 @@ export default function ProjectDevelopmentView({ () => projectResourcesByManifestAssetId(resources), [resources], ); + /** + * 本次替换的**源素材显示名**(面板上方那行小字)。 + * + * 面板改成非模态以后,画布上的「源素材」光环会被一次空白点击清掉(这是画布本来的语义), + * 而面板里只列候选、不说明在替换谁——源身份因此得在面板这一侧留一份可读的说法。 + * 取投影里的显示名(与资源卡、@ 面板同源),投影里找不到时回落到 manifest 资产名。 + */ + const resourceReplacementSourceLabel = useMemo(() => { + const assetId = resourceReplacementSource?.assetId; + if (!assetId) { + return null; + } + const projected = resources.find( + (resource) => resource.manifestAssetId === assetId, + ); + if (projected) { + return projected.label; + } + const asset = manifest.assets.find((entry) => entry.id === assetId); + return asset ? resourceDisplayName(asset) : null; + }, [manifest.assets, resourceReplacementSource, resources]); /** * 本次会话的替换血缘 → 资源卡标注(按 manifest 资产 id 取)。 * @@ -7107,11 +7235,11 @@ export default function ProjectDevelopmentView({ }, [resourceReplacementOpen, sweepResourceReplacementPreviews]); /** - * 点选态:把画布上点到的那张资源卡落成一次替换提交,或把「为什么不能替换」留在提示条上。 + * 画布点选:把点到的那张资源卡落成**本次替换目标**,或把「为什么不能替换」留在面板里。 * * 卡片的单击不参与选中:这里先登记一次性抑制(与卡片拖拽后抑制误点同一机制),随后那次 * `click` 会在 `handleResourceSelect` 里被吃掉 —— 只是这一下被消费,不是把单击全局关掉。 - * 合法目标直接走既有 `confirmResourceVersionReplacement`,写入路径与弹窗确认**完全同一条**。 + * 合法目标只记进面板(不写盘):写入路径与面板确认**完全同一条**,由用户按下确认才算数。 */ const pickResourceReplacementTarget = useCallback( (resource: ProjectResource) => { @@ -7128,49 +7256,34 @@ export default function ProjectDevelopmentView({ return; } setResourceReplacementError(null); - void confirmResourceVersionReplacement([ - resolution.replacementResourceId, - ]); + setResourceReplacementPick((current) => ({ + assetId: resolution.replacementResourceId, + revision: (current?.revision ?? 0) + 1, + })); }, - [ - confirmResourceVersionReplacement, - resourceReplacementCandidates, - resourceReplacementSource, - ], + [resourceReplacementCandidates, resourceReplacementSource], ); pickResourceReplacementTargetRef.current = pickResourceReplacementTarget; /** - * 点选态的退出:Esc、提示条「取消」、替换成功(`closeResourceVersionReplacement`)。 + * 替换面板只在资源画布上成立:切运行态 / 进 UI 编辑器就结束这次会话。 * - * Esc 挂在 **document** 上并 `stopPropagation`:资源画布自己的 Esc 挂在 window 上(清画布 - * 焦点 = 清选中 + 收浮层),document 在冒泡路径上早于 window,这里截断才能做到"Esc 只退出 - * 点选、不连带清选中"(与画布浮层「浮层打开时 Escape 归浮层所有」同一口径)。 - * - * 点选会话只在资源画布可见时存在:切运行态 / 进 UI 编辑器就结束它,否则会留下一个既看不到 - * 提示条、也按不到 Esc 的会话。 + * 非模态面板与画布是同一屏的两半,离开这块工作面它既点不到目标素材、也说不清自己在替换谁; + * Esc / × 关闭由面板自己管(见共享弹窗组件的 `nonModal`)。 */ useEffect(() => { - if (!resourceReplacementPickMode) { + if (!resourceReplacementOpen) { return undefined; } if (mode !== 'resources' || uiEditorRoute) { closeResourceVersionReplacement(); return undefined; } - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape') { - return; - } - event.stopPropagation(); - closeResourceVersionReplacement(); - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + return undefined; }, [ closeResourceVersionReplacement, mode, - resourceReplacementPickMode, + resourceReplacementOpen, uiEditorRoute, ]); @@ -8396,17 +8509,6 @@ export default function ProjectDevelopmentView({ }), [pendingResourceEdits, resourceEditSubmissions], ); - /** 入口按钮与折叠把手上显示的在途数量:只数当前项目的未终态任务(两套账本都算)。 */ - const resourceAssetGenerationInFlightCount = - resourceAssetGenerationTasks.filter( - (task) => - task.projectId === manifest.projectId && - !resourceCanvasAssetGenerationTaskIsTerminal(task), - ).length + - resourceCanvasResourceEdits.filter( - (task) => !resourceCanvasResourceEditTaskIsTerminal(task), - ).length; - const replaceResourceAssetGenerationTask = useCallback( (next: ResourceCanvasAssetGenerationTask) => { const current = resourceAssetGenerationTasksRef.current; @@ -9350,31 +9452,6 @@ export default function ProjectDevelopmentView({ ], ); - /** - * 「生成任务」开合入口。**两个页签下都常驻**:侧栏本体是无条件渲染的非模态浮层,运行态一样 - * 可见,入口若只在资源页签,用户切到运行后关掉侧栏就再也打不开了。资源页签里它排在 - * 「依赖 / 类型」排列方式之前(动作在前、排列方式收在行尾),所以这里做成一个可复用的元素, - * 由两处分支各自渲染一次——同一时刻只渲染一处,可访问名唯一。 - */ - const generationTasksEntry = ( - - ); - if (planningStartMode) { return (
) : null} - {/* - 「生成任务」入口排在「依赖 / 类型」排列方式之前:一个是开合任务侧栏的动作, - 一个是画布排列方式——动作在前、排列方式收在行尾。运行页签下由下面同一枚 - 元素补渲染(见 `generationTasksEntry`)。 - */} - {generationTasksEntry}
) : null} - {/* 运行页签(或 UI 编辑器)下资源类按钮整组收起,但「生成任务」入口必须留着。 */} - {mode === 'resources' && !uiEditorRoute - ? null - : generationTasksEntry}
{/* 布局状态提示**不进动作行**:它是一段随保存过程变长的文案(空 →「保存中」→ @@ -10276,27 +10343,6 @@ export default function ProjectDevelopmentView({ onPointerCancel={stopResourceCanvasPan} onLostPointerCapture={handleResourceCanvasLostPointerCapture} /> - {resourceReplacementPickMode ? ( -
- - 在画布上点选要替换成的素材;点击空白处不会退出 - - {resourceReplacementError ? ( - - {resourceReplacementError} - - ) : null} - -
- ) : null} {resourceCanvasBottomToolbarCategory ? ( + {/* + 版本级资源替换(直接替换):复用美术画布的参考图选择组件,但走**非模态浮层**—— + 面板锚在**画布容器**里(任务开关下方),面板开着的时候画布照常可点:在画布上点 + 目标素材即落进面板的当前选择,写入仍然只由面板的「确认」发起。 + 锚在画布容器而不是 stage:绝对定位写死相对 stage 的偏移会在工具条换行时压在工具条上 + (与任务开关同一类问题),画布容器本身就是 `position: relative`。 + 候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),组件只负责把 + 管线给的 `sourceUrl` 画出来;读不到时退回类型占位,与资源卡占位语义一致。 + */} +
+ {resourceReplacementSourceLabel ? ( +

+ {`替换源素材:${resourceReplacementSourceLabel}`} +

+ ) : null} + { + const resource = + resourceByManifestAssetId.get(asset.id) ?? null; + const previewIdentity = resource + ? (resourceCardPreviews.identityByResourceId.get( + resource.id, + ) ?? null) + : null; + return ( + + ); + }} + onCancel={closeResourceVersionReplacement} + onConfirm={(assetIds) => + void confirmResourceVersionReplacement(assetIds) + } + /> +
) : ( @@ -10742,15 +10854,69 @@ export default function ProjectDevelopmentView({
)} + {/* + 「生成任务」侧栏:常驻在**工作面右上角**(资源画布 / 运行表现层 / UI 编辑器各自一档坐标), + 可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据—— + 生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图,任务本身活在账本 + 与本地队列里。位置与开合形态照抄美术画布(开关常驻右上角 + 面板贴着它展开), + 颜色与外形仍走 AGC 的平台 token。 + */} + task.projectId === manifest.projectId, + )} + resourceEditTasks={resourceCanvasResourceEdits.filter( + (task) => + !task.restored || + !resourceCanvasResourceEditTaskIsTerminal(task), + )} + open={resourceAssetGenerationTasksPanelOpen} + onToggleOpen={() => + setResourceAssetGenerationTasksPanelOpen((current) => !current) + } + onFocusTask={focusResourceAssetGenerationTask} + placement={ + uiEditorRoute ? 'editor' : mode === 'run' ? 'run' : 'canvas' + } + /> {!uiEditorRoute ? ( -