修复 DirectProject 输入草稿丢掉引用后的换行(review 第 1 条)
- ResourceReferenceInput 的草稿投影改为「向前合并」:上一 part 是引用 chip 时先把待写文本攒在 pendingText 上,等下一个文本 part 到来再合并,段落分隔符与软换行不再被 trim() 丢掉 - 攒到最后仍只有空白的文本不入 content,避免产出 Rust validate_direct_codex_user_item 会拒绝的空 input_text - 新增 resourceReferenceInput 用例「引用后面的段落分隔不会被吞掉」,钉住 chip 后一段的 content part、派生文本与「不产生纯空白 part」 - 同步更新 canonical content 严格边界里程碑的实现结论与证据,并在 shared-memory/pitfalls 记录该缺陷的现象、原因、处理与验证
This commit is contained in:
+34
-13
@@ -174,41 +174,62 @@ class ResourceMentionOption extends MenuOption {
|
||||
}
|
||||
}
|
||||
|
||||
function appendInputText(content: DirectCodexUserContentPart[], text: string) {
|
||||
/**
|
||||
* 收集过程中的待写文本。
|
||||
*
|
||||
* 只有紧挨着上一段文本时才能直接追加;上一 part 是引用(chip)时先把文本攒起来,等
|
||||
* 下一个文本到来再合并成同一个 part。否则段落分隔符(root 子节点之间补的 `\n`)和软
|
||||
* 换行会被整段丢掉,chip 后面的文字会粘成 `@素材下一段`,并原样进 agent 与队列文案。
|
||||
* 攒到最后仍然只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不落成 part:
|
||||
* Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。
|
||||
*/
|
||||
type DraftContentCollector = {
|
||||
content: DirectCodexUserContentPart[];
|
||||
pendingText: string;
|
||||
};
|
||||
|
||||
function appendInputText(collector: DraftContentCollector, text: string) {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const { content } = collector;
|
||||
const previous = content[content.length - 1];
|
||||
if (previous?.type === 'input_text') {
|
||||
if (collector.pendingText === '' && previous?.type === 'input_text') {
|
||||
previous.text += text;
|
||||
return;
|
||||
}
|
||||
if (text.trim()) {
|
||||
content.push({ type: 'input_text', text });
|
||||
collector.pendingText += text;
|
||||
if (!collector.pendingText.trim()) {
|
||||
return;
|
||||
}
|
||||
content.push({ type: 'input_text', text: collector.pendingText });
|
||||
collector.pendingText = '';
|
||||
}
|
||||
|
||||
function collectDraftParts(
|
||||
node: LexicalNode,
|
||||
references: ChatReference[],
|
||||
content: DirectCodexUserContentPart[],
|
||||
collector: DraftContentCollector,
|
||||
) {
|
||||
if ($isTextNode(node)) {
|
||||
appendInputText(content, node.getTextContent());
|
||||
appendInputText(collector, node.getTextContent());
|
||||
return;
|
||||
}
|
||||
if ($isLineBreakNode(node)) {
|
||||
appendInputText(content, '\n');
|
||||
appendInputText(collector, '\n');
|
||||
return;
|
||||
}
|
||||
if ($isResourceReferenceNode(node)) {
|
||||
references.push(node.__reference);
|
||||
content.push(chatReferenceToContentPart(node.__reference));
|
||||
collector.content.push(chatReferenceToContentPart(node.__reference));
|
||||
return;
|
||||
}
|
||||
if ($isElementNode(node)) {
|
||||
node.getChildren().forEach((child, index) => {
|
||||
if (index > 0 && node.getType() === 'root') {
|
||||
appendInputText(content, '\n');
|
||||
appendInputText(collector, '\n');
|
||||
}
|
||||
collectDraftParts(child, references, content);
|
||||
collectDraftParts(child, references, collector);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -221,11 +242,11 @@ type DraftProjection = {
|
||||
/** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */
|
||||
function readDraftProjectionFromNodes(): DraftProjection {
|
||||
const references: ChatReference[] = [];
|
||||
const content: DirectCodexUserContentPart[] = [];
|
||||
collectDraftParts($getRoot(), references, content);
|
||||
const collector: DraftContentCollector = { content: [], pendingText: '' };
|
||||
collectDraftParts($getRoot(), references, collector);
|
||||
return {
|
||||
references: dedupeChatReferences(references),
|
||||
content,
|
||||
content: collector.content,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,55 @@ describe('ResourceReferenceInput', () => {
|
||||
expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('引用后面的段落分隔不会被吞掉:读回的 prompt 与编辑器分段逐字一致', async () => {
|
||||
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
|
||||
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
|
||||
function Controlled() {
|
||||
const composerRef = createRef<ResourceReferenceInputHandle>();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
composerRef.current?.replaceText('@hero\n把这一版改成夜景')
|
||||
}
|
||||
>
|
||||
模拟润色
|
||||
</button>
|
||||
<ResourceReferenceInput
|
||||
ref={composerRef}
|
||||
initialContent={[chatReferenceToContentPart(reference)]}
|
||||
onChange={onChange}
|
||||
assets={assets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Controlled />);
|
||||
await settleComposer();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
|
||||
await settleComposer();
|
||||
await settleComposer();
|
||||
|
||||
const draft = onChange.mock.calls.at(-1)?.[0];
|
||||
// chip 与后一段各自成段:段落分隔必须留在 canonical content 里。丢掉它读回的
|
||||
// prompt 会粘成 `@hero把这一版改成夜景`,并原样进 agent、队列文案与润色判据。
|
||||
expect(draft?.content).toEqual([
|
||||
chatReferenceToContentPart(reference),
|
||||
{ type: 'input_text', text: '\n把这一版改成夜景' },
|
||||
]);
|
||||
expect(draftText(draft)).toBe('@hero\n把这一版改成夜景');
|
||||
// 换行并进后面那段文本,而不是单独落一个纯空白 part:Rust 会拒绝空 input_text。
|
||||
expect(
|
||||
draft?.content.filter(
|
||||
(part) => part.type === 'input_text' && !part.text.trim(),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
Reference in New Issue
Block a user