自审加固:注册表按 ref 转发注册、插入成功语义与失败留痕对齐
Project CI / Backend tests (pull_request) Failing after 17s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m50s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m43s
Project CI / Repository checks (pull_request) Failing after 32s
Project CI / Frontend tests (pull_request) Successful in 2m35s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 6m26s

ActiveChatComposerHandle.insertReferences 改为返回 boolean:由句柄回答这一批有没有真的递到输入区,注册表不再用「有句柄」冒充「插进去了」
insertChatReferences 增加第三种 false(句柄报落空),且只有插入成功才 focus,失败不抢焦点
DirectProjectChatView 改为注册「按 ref 转发」的句柄,注册时不再读 composerHandleRef.current,去掉对父子 effect 顺序的隐式依赖(内层输入区重挂载也不会留下死句柄)
DirectProjectComposer 与 PlanningChatView 的转发句柄同步返回 boolean
App.tsx 的 dev 失败线索文案改为「没有可用的聊天输入区(未挂载或已卸载)」,覆盖句柄落空这一种
新增 tests/activeChatComposer.test.ts:钉住注册表合同(空批次 / 无输入区 / 句柄报落空不聚焦 / 注销身份校验);反向证伪:去掉身份校验后该用例变红
同步 docs/【功能说明】AGC聊天素材引用-2026-09-08.md 与 shared-memory 决策记录
This commit is contained in:
2026-10-04 00:12:24 +08:00
parent a22e8ee447
commit f4ada50612
8 changed files with 147 additions and 25 deletions
+2 -2
View File
@@ -1282,14 +1282,14 @@ export function App({
落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、
策划链路挂策划面,事件本身不携带这个判断。
插入失败(空批次,或此刻没有输入区挂载)不再静默:dev 下留一行线索,
插入失败(空批次,或此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索,
否则用户看到的又是一次「点了没反应」。
*/
const insertReferences = (references: readonly ChatReference[]) => {
if (insertChatReferences(references)) return;
if (import.meta.env.DEV) {
console.warn(
'[resource-reference] 引用没有落进草稿:当前没有挂载中的聊天输入区',
'[resource-reference] 引用没有落进草稿:没有可用的聊天输入区(未挂载或已卸载)',
);
}
};
@@ -3,11 +3,13 @@ import type { ChatReference } from './resourceReferences';
/**
* 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。
*
* 输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板内部用,
* 不进这里——注册表只负责跨面板的引用插入。
* `insertReferences` 回答**这一批有没有真的落进草稿**(输入区自己那份句柄可能还没挂上、
* 或者已经被卸载),注册表按它决定成功还是失败,不让「注册表里有句柄」冒充「用户看得见
* 的结果」。输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板
* 内部用,不进这里——注册表只负责跨面板的引用插入。
*/
export type ActiveChatComposerHandle = {
insertReferences: (references: ChatReference[]) => void;
insertReferences: (references: ChatReference[]) => boolean;
focus: () => void;
};
@@ -37,8 +39,9 @@ export function registerActiveChatComposer(
/**
* 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。
*
* 空批次与「此刻没有任何输入区挂载」都返回 `false`:这两件事都不能静默,
* 由调用方(`App.tsx` 的事件监听)决定怎么留痕或提示,注册表本身不吞。
* 三种情况都返回 `false`:空批次、此刻没有任何输入区挂载、注册表里的句柄已经插不进去
* (它转发的那份输入区没挂上或已卸载)。这些都不能静默,由调用方(`App.tsx` 的事件监听)
* 决定怎么留痕或提示,注册表本身不吞;只有真的插进去了才把焦点交给输入区。
*/
export function insertChatReferences(
references: readonly ChatReference[],
@@ -46,7 +49,7 @@ export function insertChatReferences(
if (references.length === 0) return false;
const handle = activeChatComposer;
if (!handle) return false;
handle.insertReferences([...references]);
if (!handle.insertReferences([...references])) return false;
handle.focus();
return true;
}
@@ -221,14 +221,24 @@ export function DirectProjectChatView({
/*
画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里
**当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路
互斥,所以同一时刻注册表里只有一个句柄。挂载期间注册、卸载注销,输入区不在位时
插入请求会拿到 `false` 而不是静默丢掉(见 `activeChatComposer.ts`)。
互斥,所以同一时刻注册表里只有一个句柄。
注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面
在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences`
返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。
*/
useEffect(() => {
const handle = composerHandleRef.current;
if (!handle) return;
return registerActiveChatComposer(handle);
}, []);
useEffect(
() =>
registerActiveChatComposer({
insertReferences: (references) => {
const handle = composerHandleRef.current;
if (!handle) return false;
return handle.insertReferences(references);
},
focus: () => composerHandleRef.current?.focus(),
}),
[],
);
return (
<section
@@ -29,7 +29,10 @@ import {
ResourceReferencePicker,
type ResourceReferencePickerHandle,
} from '../../../../../features/project-workspace/ResourceReferencePicker';
import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences';
import {
assetsSignature,
type ChatReference,
} from '../../../../../features/project-workspace/resourceReferences';
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
import type { UserContentPart } from '../../generated/UserContentPart';
@@ -188,13 +191,18 @@ export function DirectProjectComposer({
);
/*
对外句柄只做一层转发:内部输入区句柄会随编辑器重挂载换对象,这里按 ref 读最新值,
句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相。
句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相;
`insertReferences` 回答有没有真的递到输入区——注册表拿它区分「插进去了」和「这一步落空」。
*/
useImperativeHandle(
ref,
() => ({
insertReferences: (references) =>
composerRef.current?.insertReferences([...references]),
insertReferences: (references: ChatReference[]) => {
const handle = composerRef.current;
if (!handle) return false;
handle.insertReferences([...references]);
return true;
},
focus: () => composerRef.current?.focus(),
}),
[],
@@ -137,13 +137,17 @@ export function PlanningChatView({
/*
策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处,
它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份
可变句柄),注册的那个包装对象因此永远读到最新值。
可变句柄),注册的那个包装对象因此永远读到最新值,并由它回答「这一批有没有真的插进去」。
*/
useEffect(() => {
if (!composerRef) return;
return registerActiveChatComposer({
insertReferences: (references) =>
composerRef.current?.insertReferences([...references]),
insertReferences: (references) => {
const handle = composerRef.current;
if (!handle) return false;
handle.insertReferences([...references]);
return true;
},
focus: () => composerRef.current?.focus(),
});
}, [composerRef]);
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest';
import {
type ActiveChatComposerHandle,
insertChatReferences,
registerActiveChatComposer,
} from '../src/features/project-workspace/activeChatComposer';
import type { ChatReference } from '../src/features/project-workspace/resourceReferences';
function resourceReference(resourceId: string): ChatReference {
return {
type: 'resource',
resourceId,
kind: 'character',
mediaType: 'image/png',
label: resourceId,
category: 'character',
tags: [],
source: 'resource-card',
};
}
/** 一个「插入总是成功」的输入区句柄替身;`inserted` 为 false 时模拟输入区已不在位。 */
function composerHandle(inserted = true): {
handle: ActiveChatComposerHandle;
insertReferences: ReturnType<typeof vi.fn>;
focus: ReturnType<typeof vi.fn>;
} {
const insertReferences = vi.fn(() => inserted);
const focus = vi.fn();
return { handle: { insertReferences, focus }, insertReferences, focus };
}
/**
* 「活跃聊天输入区」注册表的合同。
*
* 这些语义就是 `App.tsx` 那处监听区分「引用落进草稿」与「这一步落空(要留痕)」的唯一依据,
* 所以在这里钉死;链路本身的端到端断言在 `resourceCanvasChatReferenceDrop.test.tsx` 与
* `appSurface/*.suite.ts`。
*/
describe('活跃聊天输入区注册表', () => {
it('没有任何输入区挂载时插入返回 false', () => {
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
});
it('空批次不算成功,也不打扰已挂载的输入区', () => {
const { handle, insertReferences, focus } = composerHandle();
const unregister = registerActiveChatComposer(handle);
expect(insertChatReferences([])).toBe(false);
expect(insertReferences).not.toHaveBeenCalled();
expect(focus).not.toHaveBeenCalled();
unregister();
});
it('挂载期间:整批一次交给输入区、插入成功后才聚焦,返回 true', () => {
const { handle, insertReferences, focus } = composerHandle();
const unregister = registerActiveChatComposer(handle);
expect(
insertChatReferences([
resourceReference('hero'),
resourceReference('npc'),
]),
).toBe(true);
expect(insertReferences).toHaveBeenCalledTimes(1);
expect(insertReferences).toHaveBeenCalledWith([
expect.objectContaining({ resourceId: 'hero' }),
expect.objectContaining({ resourceId: 'npc' }),
]);
expect(focus).toHaveBeenCalledTimes(1);
unregister();
});
it('句柄报「这一批没插进去」时返回 false,且不抢焦点', () => {
const { handle, focus } = composerHandle(false);
const unregister = registerActiveChatComposer(handle);
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
expect(focus).not.toHaveBeenCalled();
unregister();
});
it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => {
const first = composerHandle();
const second = composerHandle();
const unregisterFirst = registerActiveChatComposer(first.handle);
const unregisterSecond = registerActiveChatComposer(second.handle);
// 旧链路后卸载(切换项目 / 两条链路互换时可能发生):新句柄必须留在注册表里。
unregisterFirst();
expect(insertChatReferences([resourceReference('hero')])).toBe(true);
expect(second.insertReferences).toHaveBeenCalledTimes(1);
expect(first.insertReferences).not.toHaveBeenCalled();
unregisterSecond();
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
});
});
@@ -3,7 +3,7 @@
## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602)
- 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。
- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数;`insertChatReferences` 空批次或无句柄返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`,`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`;返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。
- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数;`insertChatReferences` 在空批次 / 无输入区 / 句柄报「这一批没插进去」三种情况返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`;返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。
- 边界:不采用「给 DirectProjectComposer 单独加 ref 出口 + App 按模式分流」的备选(那会把「哪个 ref 此刻是活的」继续留在检测点上)。插入仍经 `ResourceReferenceInput.insertReferences` + `focus()`(光标落在插入之后,连点两次按顺序追加)。真正根治的形态是画布与聊天的共同宿主用 context 下发插入能力;注册表语义与之一致,将来换实现不必动画布。
- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests`(195 passed / 1 skipped 文件,1902 passed / 17 skipped 用例)、`npm run test -- src/components/image-editor`(88 passed / 1401 passed)、`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。
@@ -9,8 +9,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
资源画布的「引用」按钮与「拖拽批量引用」都只做一件事:派发 window 自定义事件(`RESOURCE_REFERENCE_INSERT_EVENT` / `RESOURCE_REFERENCE_INSERT_MANY_EVENT`)。**消费者只有 `App.tsx` 一处**,事件本身不携带「插到哪个输入盒」——那由注册表回答:
- `apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts` 用模块级变量保存**当前挂载的那一个**输入区句柄(`{ insertReferences(refs), focus() }`)。
- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。
- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`:空批次或没有挂载中的输入区时返回 `false`,dev 下 `console.warn` 留一行线索(不再有可选链静默吞掉整次点击)。
- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。注册的是**按 ref 转发**的句柄、且不在注册时读输入区是否就位,所以挂载顺序与子组件重挂载都不会让注册表漏挂或指向死句柄。
- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`;返回 `false` 的三种情况(空批次、没有挂载中的输入区、注册表里的句柄报「这一批没插进去」)都不静默,dev 下 `console.warn` 留一行线索;只有真的插进去了才把焦点交给输入区。
- `chatComposerRef` 只留给策划输入盒自己的提交(`getDraft` / `clear`),不再承担跨面板插入。
这是 2026-09-22 DirectProject 拆分后的回归修复(issue #602):当时 `composerRef={chatComposerRef}` 只剩策划面一处,而 `directProjectMode` 的提前 return 让普通项目永远走不到那条赋值,`chatComposerRef.current?.insertReferences(...)` 的可选链把整次调用静默丢掉;同一批合并冲突还把 2026-09-21 新加的批量监听整段丢了,拖拽批量引用连监听者都没有。两条现在都由上面这一处收口。
@@ -22,6 +22,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
| 工具条「引用」(键盘 + 鼠标两条通路)落进 DirectProject 草稿,光标留在插入之后 | `apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的「工具条里的「引用」把素材 @ 进真实聊天草稿」 |
| 拖拽批量引用整批一次落进草稿、顺序 = 拖动集合顺序、零坐标写入 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` |
| 未登记素材不出「引用」按钮、拖到对话栏只给原因 | `tests/resourceCanvasChatReferenceDrop.test.tsx` 的「未登记素材」用例、`tests/resourceCardReferenceDropModel.test.ts` |
| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` |
| 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 |
## 引用来源由宿主注入(2026-09-22)