完成DirectProject composer canonical content收口

输入区草稿只保留 Lexical content 数组

Direct 与 Planning 提交边界直接读取 canonical content

润色保留引用解析并保护附件节点

通过单向 DTO 适配 legacy text 引用与附件消费
This commit is contained in:
2026-09-21 14:37:49 +08:00
parent ec408bfafd
commit 4698527025
8 changed files with 157 additions and 264 deletions
+7 -63
View File
@@ -87,15 +87,10 @@ import {
resolveChatProjectPath,
} from './features/project-workspace/projectCommandPolicy';
import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput';
import type {
ChatComposerDraft,
ChatReference,
} from './features/project-workspace/resourceReferences';
import {
directCodexContentToPromptText,
directCodexContentToLegacyContentDto,
hasMeaningfulDirectCodexContent,
RESOURCE_REFERENCE_INSERT_EVENT,
resourceReferenceFromAsset,
type ResourceReferenceInsertEventDetail,
} from './features/project-workspace/resourceReferences';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
@@ -122,7 +117,6 @@ import {
DirectProjectChatView,
type DirectProjectInitialTurn,
} from './view/project-development/chat/DirectProjectChatView';
import type { DirectCodexUserContentPart } from './view/project-development/chat/generated/DirectCodexUserContentPart';
import { PlanningChatView } from './view/project-development/planning/PlanningChatView';
import { planningStateNeedsRuntimeRefresh } from './view/project-development/planning/planningLane';
import {
@@ -375,15 +369,7 @@ export function App({
);
}
const [chatInput, setChatInput] = useState('');
const [chatReferences, setChatReferences] = useState<ChatReference[]>([]);
const [chatContent, setChatContent] = useState<DirectCodexUserContentPart[]>(
[],
);
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
useEffect(() => {
setChatContent([]);
}, [localProject?.projectPath]);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [planningRuntime, setPlanningRuntime] =
useState<AgentRuntimeState | null>(null);
@@ -1883,8 +1869,6 @@ export function App({
setProjectPath(openedProject.projectPath);
setWorkspaceProjectKind(projectKind);
setLocalProject(openedProject);
setChatReferences([]);
setChatContent([]);
setManifest(openedProject.manifest);
setAgentRuntimeById({});
setMessages(conversationMessages);
@@ -1926,42 +1910,6 @@ export function App({
}
}
function handleChatComposerChange(draft: ChatComposerDraft) {
const content = draft.content;
setChatContent(content);
setChatInput(directCodexContentToPromptText(content, manifest.assets));
setChatReferences(
content.flatMap((part) => {
if (part.type === 'agc_resource_reference') {
const asset = manifest.assets.find(
(item) => item.id === part.resourceId,
);
return asset
? [resourceReferenceFromAsset(asset, 'asset-picker')]
: [];
}
if (part.type === 'agc_runtime_region_reference') {
return [
{
type: 'runtime-region' as const,
label: part.label,
runId: part.runId ?? undefined,
versionId: part.versionId ?? undefined,
elementTag: part.elementTag ?? undefined,
elementRole: part.elementRole ?? undefined,
text: part.text ?? undefined,
width: part.width ?? undefined,
height: part.height ?? undefined,
resourceIds: part.resourceIds,
source: 'runtime-picker' as const,
},
] as ChatReference[];
}
return [] as ChatReference[];
}),
);
}
useEffect(() => {
const handleResourceReferenceInsert = (event: Event) => {
const detail = (event as CustomEvent<ResourceReferenceInsertEventDetail>)
@@ -2688,12 +2636,13 @@ export function App({
function handlePlanningChatSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const prompt = chatInput.trim();
const content = chatContent;
const canonicalPrompt = directCodexContentToPromptText(
const content = chatComposerRef.current?.getDraft().content ?? [];
const legacyContent = directCodexContentToLegacyContentDto(
content,
manifest.assets,
);
const prompt = legacyContent.text.trim();
const canonicalPrompt = legacyContent.text;
if (agentRuntimeNeedsUserInput(planningRuntimeRef.current)) {
setProjectChatError('请先回答项目总控 Agent 当前的澄清问题');
return;
@@ -2710,7 +2659,7 @@ export function App({
}
planningChatShouldFollowLatestRef.current = true;
const clientTurnId = createAgentChatRunId('planning-v2-turn');
setChatInput('');
chatComposerRef.current?.clear();
setMessages((current) => [
...current,
{
@@ -2724,9 +2673,7 @@ export function App({
void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId });
return;
}
setChatInput('');
setChatReferences([]);
setChatContent([]);
chatComposerRef.current?.clear();
setMessages((current) => [
...current,
{
@@ -2761,8 +2708,6 @@ export function App({
<PlanningChatView
initialPlanningPrompt={initialPlanningPrompt}
activeVersionId={chatActiveVersionId}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
hiddenConversationCount={hiddenConversationCount}
@@ -2770,7 +2715,6 @@ export function App({
messagesRef={planningChatMessagesRef}
needsUserInput={planningNeedsUserInput}
onCancelConfirmation={cancelUiCommandConfirmation}
onChatInputChange={handleChatComposerChange}
onConfirmConfirmation={confirmUiCommand}
onScroll={handlePlanningChatScroll}
onShowEarlierMessages={showEarlierConversationMessages}
@@ -41,7 +41,6 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -104,8 +103,6 @@ import {
import { usePromptPolish } from './usePromptPolish';
type ResourceReferenceInputProps = {
value?: string;
references?: ChatReference[];
onChange?: (draft: ChatComposerDraft) => void;
onEditorStateChange?: (editorState: EditorState) => void;
initialContent?: DirectCodexUserContentPart[];
@@ -241,20 +238,6 @@ type DraftProjection = {
content: DirectCodexUserContentPart[];
};
/** 引用在草稿文本里的稳定键:resource 用资源 id,skill 用技能名,其余用显示名。 */
function referenceDraftKey(reference: ChatReference) {
if (reference.type === 'resource') return reference.resourceId;
if (reference.type === 'skill') return reference.name;
return reference.label;
}
/** 引用回读成草稿文本时的 tokenskill 是 `$name`resource 与 runtime region 是 `@显示名`。 */
function referenceDraftToken(reference: ChatReference) {
return reference.type === 'skill'
? `$${reference.name}`
: `@${reference.label}`;
}
/** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */
function readDraftProjectionFromNodes(): DraftProjection {
const references: ChatReference[] = [];
@@ -269,31 +252,7 @@ function readDraftProjectionFromNodes(): DraftProjection {
/** 按编辑器自己的口径读 canonical 草稿;只能在 Lexical 读/更新上下文里调用。 */
function readDraftFromNodes(): ChatComposerDraft {
const projection = readDraftProjectionFromNodes();
const labels = new Map(
projection.references.map((reference) => [
referenceDraftKey(reference),
referenceDraftToken(reference),
]),
);
const text = projection.content
// 与 directCodexContentToPromptText 保持同一口径:附件引用是 `@名称`,不是 `$名称`。
.map((part) => {
if (part.type === 'input_text') return part.text;
if (part.type === 'agc_resource_reference') {
return labels.get(part.resourceId) ?? `@${part.resourceId}`;
}
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
if (part.type === 'agc_skill_reference') return `$${part.name}`;
return '';
})
.join('')
.trim();
return {
text,
references: projection.references,
content: projection.content,
};
return { content: projection.content };
}
// The pure projection is exported for submit-time reads and focused tests.
@@ -302,7 +261,7 @@ export function readResourceReferenceDraft(
editorState: EditorState | null,
): ChatComposerDraft {
if (!editorState) {
return { text: '', references: [], content: [] };
return { content: [] };
}
return editorState.read(readDraftFromNodes);
}
@@ -399,20 +358,39 @@ function buildDraftSegments(
return lines;
}
function applyDraftToRoot(value: string, references: ChatReference[]) {
const root = $getRoot();
root.clear();
buildDraftSegments(value, references).forEach((segments) => {
const paragraph = $createParagraphNode();
segments.forEach((segment) => {
if (segment.kind === 'text') {
if (segment.text) paragraph.append($createTextNode(segment.text));
return;
/**
* 润色回写仍按文本中的 token 精确恢复资源/Skill/runtime chip;附件没有可安全反解析的
* 稳定 token,因此保留原 attachment part 并放在新文本之后。
* TODO: 提出“润色精确恢复附件原位置”的产品能力后,再把附件位置纳入协议,不猜字符串。
*/
function applyPolishedTextToRoot(
value: string,
current: DraftProjection,
assetsById: ReadonlyMap<string, GameCreationAppAssetManifestEntry>,
) {
const lines = value.split(/\r?\n/u);
const rebuiltContent: DirectCodexUserContentPart[] = [];
buildDraftSegments(value, current.references).forEach(
(segments, lineIndex) => {
segments.forEach((segment) => {
if (segment.kind === 'text') {
if (segment.text)
rebuiltContent.push({ type: 'input_text', text: segment.text });
return;
}
rebuiltContent.push(chatReferenceToContentPart(segment.reference));
});
if (lineIndex < lines.length - 1) {
rebuiltContent.push({ type: 'input_text', text: '\n' });
}
paragraph.append($createResourceReferenceNode(segment.reference));
});
root.append(paragraph);
});
},
);
rebuiltContent.push(
...current.content.filter(
(part) => part.type === 'agc_attachment_reference',
),
);
applyContentToRoot(rebuiltContent, assetsById);
}
function referenceFromContentPart(
@@ -567,8 +545,6 @@ function $staleResourceReferenceNodes(
}
function ResourceReferenceEditor({
value,
references,
onChange,
onEditorStateChange,
initialContent,
@@ -833,7 +809,7 @@ function ResourceReferenceEditor({
replaceText: (text: string) => {
editor.update(() => {
const current = readDraftProjectionFromNodes();
applyDraftToRoot(text, current.references);
applyPolishedTextToRoot(text, current, assetsById);
$getRoot().selectEnd();
});
},
@@ -873,39 +849,6 @@ function ResourceReferenceEditor({
});
}, [assetsById, editor, initialContent]);
// 受控聊天入口在清空/恢复草稿时同步编辑器;普通输入变更若已与当前内容一致则不重建,
// 避免每个按键都把光标跳到末尾。
//
// 这里必须是 layout effect`value` / `references` 只是宿主对编辑器草稿的回显,宿主状态
// 更新会晚于编辑器的 Lexical 提交。用被动 effect 时,一次滞后渲染的回调可能在用户已经
// 继续输入之后才执行,它读到的是旧 `value` 加新编辑器状态,就会把整份草稿重建掉
// (编辑器再通过 onChange 把空草稿写回宿主,来回清空)。layout effect 与本次提交同帧执行,
// 读到的 props 与编辑器状态属于同一次提交。
useLayoutEffect(() => {
if (value === undefined && references === undefined) return;
const desiredValue = value ?? '';
const desiredRefs = references ?? [];
const current = readResourceReferenceDraft(editor.getEditorState());
const currentRefKey = current.references
?.map(
(reference) =>
`${reference.type}:${reference.type === 'resource' ? reference.resourceId : reference.type === 'skill' ? reference.name : reference.label}`,
)
.join('|');
const desiredRefKey = desiredRefs
.map(
(reference) =>
`${reference.type}:${reference.type === 'resource' ? reference.resourceId : reference.type === 'skill' ? reference.name : reference.label}`,
)
.join('|');
if (current.text === desiredValue && currentRefKey === desiredRefKey)
return;
editor.update(() => {
applyDraftToRoot(desiredValue, desiredRefs);
$getRoot().selectEnd();
});
}, [editor, references, value]);
// 资源改名后刷新已有引用 chip 的显示名:改写节点会触发 OnChangePlugin
// 把带新显示名的草稿同步回父级,chip 与候选列表都不会残留旧名。
// Lexical 的更新可能排到微任务里提交,这里额外挂一次更新监听兜底。
@@ -1026,8 +969,6 @@ function ResourceReferenceEditor({
const acknowledgedDraftKeyRef = useRef<string | null>(null);
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
const liveDraftRef = useRef<ChatComposerDraft>({
text: directCodexContentToPromptText(initialContent ?? [], assets),
references: [],
content: initialContent ?? [],
});
const [draftText, setDraftText] = useState(() =>
@@ -1043,11 +984,11 @@ function ResourceReferenceEditor({
(text: string) => {
editor.update(() => {
const current = readDraftProjectionFromNodes();
applyDraftToRoot(text, current.references);
applyPolishedTextToRoot(text, current, assetsById);
$getRoot().selectEnd();
});
},
[editor],
[assetsById, editor],
);
const readPromptText = useCallback(
@@ -10,6 +10,7 @@ import {
buildGameCreationAppAssetTagLibrary,
type GameCreationAppAssetTagLibraryEntry,
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem';
@@ -56,10 +57,15 @@ export type ChatReference =
| SkillReference;
export type ChatComposerDraft = {
/** Lexical 节点按顺序投影出的 canonical user content,唯一事实源。 */
content: DirectCodexUserContentPart[];
};
/** 仅供仍未迁移的旧调用方消费的单向投影,不得回传给 ResourceReferenceInput。 */
export type DirectCodexLegacyContentDto = {
text: string;
references: ChatReference[];
/** Lexical 顺序对应的 canonical user content,是草稿的唯一结构化来源。 */
content: DirectCodexUserContentPart[];
attachments: DirectCodexUserAttachmentReferencePart[];
};
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
@@ -95,8 +101,6 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
}
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
text: '',
references: [],
content: [],
};
@@ -177,10 +181,49 @@ export function directCodexUserItemFromContent(
content: readonly DirectCodexUserContentPart[],
id: string,
): DirectCodexUserItem {
return chatComposerDraftToDirectCodexUserItem(
{ text: '', references: [], content: [...content] },
id,
);
return chatComposerDraftToDirectCodexUserItem({ content: [...content] }, id);
}
/** canonical content → legacy text + reference + attachment DTO。只允许单向翻译。 */
export function directCodexContentToLegacyContentDto(
content: readonly DirectCodexUserContentPart[],
assets: readonly GameCreationAppAssetManifestEntry[],
): DirectCodexLegacyContentDto {
const references: ChatReference[] = [];
content.forEach((part) => {
if (part.type === 'agc_resource_reference') {
const asset = assets.find((item) => item.id === part.resourceId);
if (asset)
references.push(resourceReferenceFromAsset(asset, 'asset-picker'));
return;
}
if (part.type === 'agc_runtime_region_reference') {
references.push({
type: 'runtime-region',
label: part.label,
runId: part.runId ?? undefined,
versionId: part.versionId ?? undefined,
elementTag: part.elementTag ?? undefined,
elementRole: part.elementRole ?? undefined,
text: part.text ?? undefined,
width: part.width ?? undefined,
height: part.height ?? undefined,
resourceIds: part.resourceIds,
source: 'runtime-picker',
});
return;
}
if (part.type === 'agc_skill_reference') {
references.push({ type: 'skill', name: part.name });
}
});
return {
text: directCodexContentToPromptText(content, assets),
references,
attachments: content.flatMap((part) =>
part.type === 'agc_attachment_reference' ? [part] : [],
),
};
}
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
@@ -116,12 +116,9 @@ export function DirectProjectChatView({
attachmentNotice,
cancelQueuedTurn,
cancelTurn,
chatInput,
chatReferences,
composerNotice,
directEntries,
directTurnRunning,
handleComposerDraft,
historyHasMore,
loadEarlierHistory,
localMessages,
@@ -229,14 +226,11 @@ export function DirectProjectChatView({
attachments={attachments}
attachmentNotice={attachmentNotice}
queuedTurns={queuedTurns}
chatInput={chatInput}
chatReferences={chatReferences}
composerNotice={composerNotice}
busy={busy}
turnCancelling={turnCancelling}
onCancelQueuedTurn={cancelQueuedTurn}
onCancelTurn={() => void cancelTurn()}
onChatInputChange={handleComposerDraft}
onRemoveAttachment={removeAttachment}
onSubmit={submit}
onUploadFiles={(files) => void uploadFiles(files)}
@@ -1,5 +1,4 @@
import { ArrowUp, AtSign } from 'lucide-react';
import type { FormEventHandler } from 'react';
import { useRef, useState } from 'react';
import type {
@@ -14,11 +13,9 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../../../../../features/project-workspace/ResourceReferenceInput';
import type {
ChatComposerDraft,
ChatReference,
} from '../../../../../features/project-workspace/resourceReferences';
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
import { directCodexAttachmentContentParts } from '../../conversation/directCodexTurnAttachments';
import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart';
import type { QueuedChatTurn } from './chatComposerQueue';
import {
ComposerAttachmentMenu,
@@ -42,14 +39,11 @@ export function DirectProjectComposer({
attachments,
attachmentNotice,
queuedTurns,
chatInput,
chatReferences,
composerNotice,
busy,
turnCancelling,
onCancelQueuedTurn,
onCancelTurn,
onChatInputChange,
onRemoveAttachment,
onSubmit,
onUploadFiles,
@@ -60,16 +54,13 @@ export function DirectProjectComposer({
attachments: DirectCodexTurnAttachment[];
attachmentNotice: string;
queuedTurns: QueuedChatTurn[];
chatInput: string;
chatReferences: ChatReference[];
composerNotice: string;
busy: boolean;
turnCancelling: boolean;
onCancelQueuedTurn: (id: string) => void;
onCancelTurn: () => void;
onChatInputChange: (draft: ChatComposerDraft) => void;
onRemoveAttachment: (index: number) => void;
onSubmit: FormEventHandler<HTMLFormElement>;
onSubmit: (content: DirectCodexUserContentPart[]) => boolean;
onUploadFiles: (files: readonly File[]) => void;
}) {
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
@@ -104,7 +95,15 @@ export function DirectProjectComposer({
const ready = modelSelectRef.current
? await modelSelectRef.current.ensureUsable()
: modelReady;
if (ready) onSubmit(event);
if (ready) {
const content = [
...(composerRef.current?.getDraft().content ?? []),
...directCodexAttachmentContentParts(attachments),
];
if (onSubmit(content)) {
composerRef.current?.clear();
}
}
} finally {
modelValidateInFlightRef.current = false;
setModelValidating(false);
@@ -129,10 +128,7 @@ export function DirectProjectComposer({
projectPath={projectPath ?? ''}
disabled={modelValidating}
rows={3}
value={chatInput}
references={chatReferences}
placeholder="描述你的想法,或 @ 引用素材"
onChange={onChatInputChange}
showTriggerButton={false}
/>
<div className="project-chat-composer-controls">
@@ -1,4 +1,4 @@
import { type FormEvent, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
CONVERSATION_INITIAL_VISIBLE_COUNT,
@@ -9,12 +9,9 @@ import type { ChatMessage, DirectTurnCancelView } from '../../../../app/types';
import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation';
import {
type ChatComposerDraft,
type ChatReference,
directCodexContentToPromptText,
directCodexUserItemFromContent,
hasMeaningfulDirectCodexContent,
resourceReferenceFromAsset,
} from '../../../../features/project-workspace/resourceReferences';
import { captureAgentRuntimeError } from '../../../../services/errorReporting';
import { requestPlatformSessionRefresh } from '../../../../services/platformSession';
@@ -42,7 +39,6 @@ import {
withDirectCodexSessionRefresh,
} from '../conversation/directCodexSession';
import {
directCodexAttachmentContentParts,
type DirectCodexTurnAttachment,
toDirectCodexTurnAttachments,
} from '../conversation/directCodexTurnAttachments';
@@ -117,11 +113,6 @@ export function useDirectProjectChatController({
const [statusNotice, setStatusNotice] = useState('');
const [turnCancelling, setTurnCancelling] = useState(false);
const [turnBusy, setTurnBusy] = useState(false);
const [chatInput, setChatInput] = useState('');
const [chatReferences, setChatReferences] = useState<ChatReference[]>([]);
const [chatContent, setChatContent] = useState<DirectCodexUserContentPart[]>(
[],
);
const [localMessages, setLocalMessages] = useState<ChatMessage[]>([]);
// 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里:
// controller 只读投影后的条目与回合忙态,不再直接持有线程状态。
@@ -151,7 +142,6 @@ export function useDirectProjectChatController({
setStatusNotice('');
setQueuedTurns([]);
queuedTurnsRef.current = [];
setChatContent([]);
setLocalMessages([]);
setHistoryHasMore(false);
historyOldestItemIdRef.current = null;
@@ -311,44 +301,7 @@ export function useDirectProjectChatController({
);
}
function handleComposerDraft(draft: ChatComposerDraft) {
const content = draft.content;
setChatContent(content);
setChatInput(directCodexContentToPromptText(content, assets));
setChatReferences(
content.flatMap((part) => {
if (part.type === 'agc_resource_reference') {
const asset = assets.find((item) => item.id === part.resourceId);
return asset
? [resourceReferenceFromAsset(asset, 'asset-picker')]
: [];
}
if (part.type === 'agc_runtime_region_reference') {
return [
{
type: 'runtime-region' as const,
label: part.label,
runId: part.runId ?? undefined,
versionId: part.versionId ?? undefined,
elementTag: part.elementTag ?? undefined,
elementRole: part.elementRole ?? undefined,
text: part.text ?? undefined,
width: part.width ?? undefined,
height: part.height ?? undefined,
resourceIds: part.resourceIds,
source: 'runtime-picker' as const,
},
] as ChatReference[];
}
return [] as ChatReference[];
}),
);
}
function clearDraftInput() {
setChatInput('');
setChatReferences([]);
setChatContent([]);
setAttachments([]);
setAttachmentNotice('');
}
@@ -358,13 +311,8 @@ export function useDirectProjectChatController({
setComposerNotice('');
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const content = [
...chatContent,
...directCodexAttachmentContentParts(attachments),
];
if (!hasMeaningfulDirectCodexContent(content)) return;
function submit(content: DirectCodexUserContentPart[]): boolean {
if (!hasMeaningfulDirectCodexContent(content)) return false;
if (turnBusyRef.current || currentTurnRunning) {
const clientTurnId = createDirectProjectTurnId();
if (
@@ -378,14 +326,15 @@ export function useDirectProjectChatController({
) {
// 只清草稿:enqueueTurn 刚写入的「已加入发送队列」提示必须留给用户看到。
clearDraftInput();
return true;
}
return;
return false;
}
const prompt = chatInput.trim();
const prompt = directCodexContentToPromptText(content, assets).trim();
if (prompt === '/history') {
clearPendingInput();
void reloadHistory();
return;
return true;
}
const clientTurnId = createDirectProjectTurnId();
const userItem = directCodexUserItemFromContent(
@@ -398,6 +347,7 @@ export function useDirectProjectChatController({
};
clearPendingInput();
startTurn(turn);
return true;
}
/**
@@ -758,13 +708,9 @@ export function useDirectProjectChatController({
attachmentNotice,
cancelQueuedTurn,
cancelTurn,
chatContent,
chatInput,
chatReferences,
composerNotice,
directEntries,
directTurnRunning: currentTurnRunning,
handleComposerDraft,
historyHasMore,
loadEarlierHistory,
localMessages,
@@ -22,10 +22,6 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../../../features/project-workspace/ResourceReferenceInput';
import type {
ChatComposerDraft,
ChatReference,
} from '../../../features/project-workspace/resourceReferences';
import { formatClockTime } from '../chat/components/ToolCallGroup/toolCallGroupPresentation';
import {
DesignAgentPendingActions,
@@ -85,8 +81,6 @@ function workspaceStatusForDisplay(workspaceStatus: string) {
type PlanningChatViewProps = {
activeVersionId?: string | null;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: RefObject<ResourceReferenceInputHandle | null>;
controlBusy: boolean;
@@ -97,7 +91,6 @@ type PlanningChatViewProps = {
messagesRef: RefObject<HTMLDivElement | null>;
needsUserInput: boolean;
onCancelConfirmation: () => void;
onChatInputChange: (draft: ChatComposerDraft) => void;
onConfirmConfirmation: () => void;
onScroll: UIEventHandler<HTMLDivElement>;
onShowEarlierMessages: () => void;
@@ -140,8 +133,6 @@ type PlanningChatViewProps = {
export function PlanningChatView({
activeVersionId = null,
chatInput,
chatReferences,
chatProjectAssets,
composerRef,
controlBusy,
@@ -152,7 +143,6 @@ export function PlanningChatView({
messagesRef,
needsUserInput,
onCancelConfirmation,
onChatInputChange,
onConfirmConfirmation,
onScroll,
onShowEarlierMessages,
@@ -368,12 +358,9 @@ export function PlanningChatView({
projectPath={projectPath}
disabled={composerDisabled}
rows={3}
value={chatInput}
references={chatReferences}
showTriggerButton={false}
showPolishAction={false}
placeholder=""
onChange={onChatInputChange}
/>
<div className="project-chat-composer-controls">
<div className="project-chat-composer-controls-right">
@@ -343,6 +343,48 @@ describe('ResourceReferenceInput', () => {
expect(draftText(draft)).toBe('@hero\n把这一版改成夜景');
});
test('润色回写保留原附件 part,不把 attachment chip 丢出 canonical content', async () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const attachment = {
type: 'agc_attachment_reference' as const,
name: '参考图.png',
mediaType: 'image/png',
size: 12,
localPath: 'assets/reference.png',
status: 'imported',
};
const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<>
<button
type="button"
onClick={() => composerRef.current?.replaceText('@hero 润色后的需求')}
>
</button>
<ResourceReferenceInput
ref={composerRef}
initialContent={[chatReferenceToContentPart(reference), attachment]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</>,
);
await settleComposer();
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
await settleComposer();
const content = onChange.mock.calls.at(-1)?.[0].content;
expect(content).toEqual([
chatReferenceToContentPart(reference),
{ type: 'input_text', text: ' 润色后的需求' },
attachment,
]);
});
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();