修复:候选菜单的 key 改用引用身份,同名素材不再撞 key

- 导出 resourceReferences 的 chatReferenceKey(只认稳定身份、不含显示名的身份键),并加注释说明它为什么不能省
- ReferenceMentionOption 由 super(chatReferenceMentionToken(...)) 改为 super(chatReferenceKey(...)):characters/hero.png 与 enemies/hero.png 都展开成 @hero,拿显示 token 当 key 会让 React 撞 key、复用错 DOM,键盘高亮与选中落到另一条候选上
- referenceSourceProviders 用例补一批同名素材:断言两条候选的显示 token 相同、身份键不同
This commit is contained in:
2026-09-22 17:06:08 +08:00
parent 3fdde6f51d
commit fbfc7e5e62
3 changed files with 37 additions and 7 deletions
@@ -57,6 +57,7 @@ import {
type ChatComposerDraft,
type ChatReference,
chatReferenceDisplayHint,
chatReferenceKey,
chatReferenceMentionToken,
chatReferenceToContentPart,
dedupeChatReferences,
@@ -121,7 +122,9 @@ class ReferenceMentionOption extends MenuOption {
hint: string;
constructor(reference: ChatReference) {
super(chatReferenceMentionToken(reference));
// key 必须是引用的唯一身份:显示 token(`@显示名`)不唯一,同名素材会撞 key 并让
// React 复用错 DOM、键盘高亮落到别的候选上。显示 token 只留给 `label`。
super(chatReferenceKey(reference));
this.reference = reference;
this.label = chatReferenceMentionToken(reference);
this.hint = chatReferenceDisplayHint(reference);
@@ -788,7 +788,13 @@ function runtimeRegionReferenceDiscriminators(
}:${reference.width ?? ''}:${reference.height ?? ''}:${resourceIds}`;
}
function chatReferenceKey(reference: ChatReference) {
/**
* 引用的身份键:只认稳定身份,**不含显示名**。
*
* 同一个显示名可以来自两条不同引用(`characters/hero.png` 与 `enemies/hero.png` 都展开成
* `@hero`),所以去重与候选菜单的 React key 都必须用它,而不是显示 token。
*/
export function chatReferenceKey(reference: ChatReference) {
if (reference.type === 'resource') {
return `resource:${reference.resourceId}:${reference.source}`;
}
@@ -10,11 +10,13 @@ import { attachmentReferenceProvider } from '../src/features/project-workspace/r
import { createResourceReferenceProvider } from '../src/features/project-workspace/reference-source/resourceReferenceProvider';
import { runtimeRegionReferenceProvider } from '../src/features/project-workspace/reference-source/runtimeRegionReferenceProvider';
import { useSkillReferenceProvider } from '../src/features/project-workspace/reference-source/skillReferenceProvider';
import type {
AttachmentReference,
ChatReference,
ResourceReference,
RuntimeRegionReference,
import {
type AttachmentReference,
type ChatReference,
chatReferenceKey,
chatReferenceMentionToken,
type ResourceReference,
type RuntimeRegionReference,
} from '../src/features/project-workspace/resourceReferences';
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
@@ -106,6 +108,25 @@ describe('资源引用 provider', () => {
expect(provider.match?.('missing')).toEqual([]);
});
it('同名不同目录的素材是两条候选:显示名相同,但身份键不同', () => {
const sameName = createResourceReferenceProvider({
assets: [
asset('asset-hero-a', 'character', 'image/png', 'characters/hero.png'),
asset('asset-hero-b', 'character', 'image/png', 'enemies/hero.png'),
],
});
const candidates = sameName.match?.('') ?? [];
// 显示 token 会撞(都是 `@hero`),所以候选菜单的 key 不能拿 token 当身份。
expect(candidates.map((item) => chatReferenceMentionToken(item))).toEqual([
'@hero',
'@hero',
]);
expect(new Set(candidates.map((item) => chatReferenceKey(item))).size).toBe(
2,
);
});
it('`toReference` 只认资源 part:资产已删除时不合成引用', () => {
expect(provider.toReference({ type: 'input_text', text: '看素材' })).toBe(
null,