import type { UIEventHandler } from 'react'; import type { Ref } from 'react'; import { useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; import { directCodexUserItemFromContent } from '../../../features/project-workspace/resourceReferences'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; import { ApprovalModeDialog } from '../ApprovalModeDialog'; import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader'; import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer'; import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation'; import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog'; import { type DirectProjectConversationReadGate, type DirectProjectConversationWriteGate, useDirectProjectChatController, } from './controller/useDirectProjectChatController'; import { useDirectProjectManifest } from './controller/useDirectProjectManifest'; import { useDirectProjectTurnStatus } from './controller/useDirectProjectTurnStatus'; import { createDirectProjectTurnId, directCodexConversationMessageId, type DirectProjectTurnInput, } from './conversation/directCodexConversation'; import { buildDirectChatTurns } from './conversation/directTurnPresentation'; import type { DirectCodexUserContentPart } from './generated/DirectCodexUserContentPart'; /** * DirectProject 对外只有这一条命令式出口:工作台壳跑完运行/预览这类工作台级动作后, * 把结果告诉用户。聊天容器持有会话真相,壳不持有聊天消息,只把这句话交给聊天的本地 * 消息流。 */ export type DirectProjectChatHandle = { announce: (text: string) => void; }; /** * 首页(或立项链路)带过来的首轮需求:项目打开后由聊天自己认领并发出。 * * 认领记录按「项目路径 + claimScope」与 Design / Planning 入口共用一份 * (见 `app/initialTurnClaims`),所以同一条首轮需求不会被两个入口各发一次。 */ export type DirectProjectInitialTurn = { projectPath: string; /** * 入口首轮需求的 canonical 内容:文本、`@` 素材引用和附件引用都内联在这一份 content * 里,和 Composer 提交的回合保持同一种形状。 */ content: DirectCodexUserContentPart[]; creationType?: DirectProjectTurnInput['creationType']; claimScope: string; }; /** * DirectProject 的公开入参:只有项目身份、入口带进来的首轮需求和两条权限门。 * * 项目清单(素材/版本/projectId)、订阅、首屏锚点与历史分页、发送与 FIFO 队列、附件、 * 终止、草稿、本地消息、状态文案和错误提示都由这个组件自己持有;工作台壳不再传 Direct * 专属 state/ref/effect,也不再有 Design Agent / Planning V2 的运行态。 */ export type DirectProjectChatViewProps = { /** 绑定的项目路径:项目身份、素材与版本都从这份项目清单读取。 */ projectPath: string | null; /** 入口带进来的首轮需求;未提供时不自动发送任何回合。 */ initialTurn?: DirectProjectInitialTurn | null; /** * 项目对话读权限门:策略要求确认时由工作台壳入队,聊天不重复判定权限。 * 必填:这道门是聊天发起读取/回合的授权判据,缺省放行等于绕过工作台壳的权限判定。 */ ensureConversationReadAllowed: DirectProjectConversationReadGate; /** 项目对话写权限门:只在发起回合时检查;同样必填,缺省会让未传入的调用方静默跳过授权。 */ ensureConversationWriteAllowed: DirectProjectConversationWriteGate; /** 发布入口:由工作台壳持有导出与发布面板,聊天只提供触发按钮。 */ onRequestGamePublish?: () => void; /** * 工作台壳入队的项目权限确认:聊天只展示并转发确认/取消, * 策略读取、拒绝判定和确认后的动作仍由工作台壳持有。 */ pendingConfirmation?: PendingUiConfirmation | null; onConfirmConfirmation?: () => void; onCancelConfirmation?: () => void; /** 工作台壳把运行/预览这类动作结果交给聊天的出口。 */ ref?: Ref; }; /** 首轮那条本地用户消息展示入口原话:附件与引用仍按 canonical content 进入回合。 */ function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) { return content .filter((part) => part.type === 'input_text') .map((part) => part.text) .join('') .trim(); } export function DirectProjectChatView({ onRequestGamePublish, onConfirmConfirmation, onCancelConfirmation, pendingConfirmation = null, projectPath, initialTurn = null, ensureConversationReadAllowed, ensureConversationWriteAllowed, ref, }: DirectProjectChatViewProps) { const { assets, projectId, refresh, versions } = useDirectProjectManifest(projectPath); const messagesRef = useRef(null); const shouldFollowLatestRef = useRef(true); const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); const [approvalOpen, setApprovalOpen] = useState(false); const [approvalMode, setApprovalMode] = useState('strict'); const [approvalNotice, setApprovalNotice] = useState(''); const chat = useDirectProjectChatController({ assets, enabled: Boolean(projectPath), ensureConversationReadAllowed, ensureConversationWriteAllowed, onRuntimeError: setRuntimeNotice, projectId, projectPath, refreshManifest: refresh, }); const { appendLocalMessage, attachmentNotice, attachmentsImporting, cancelQueuedTurn, cancelTurn, composerNotice, directEntries, directTurnRunning, historyHasMore, loadEarlierHistory, localMessages, pendingUserItemId, queuedTurns, startInitialTurn, statusNotice, submit, turnBusy, turnCancelling, uploadFiles, } = chat; // 这份投影要参与下游 memo 的判据,必须自己先缓存:不缓存的话每次渲染都是一份新数组, // `useDirectProjectTurnStatus` 里以 `turns` 为判据的 `useMemo` 永远命中不了,那层 memo // 就成了死代码,读代码的人还会误以为 `turnStatus` 是引用稳定的。 const directTurns = useMemo( () => buildDirectChatTurns({ entries: directEntries, localMessages, turnRunning: directTurnRunning, pendingUserItemId, }), [directEntries, localMessages, directTurnRunning, pendingUserItemId], ); // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。 const turnStatus = useDirectProjectTurnStatus({ turnRunning: directTurnRunning, turnBusy, turns: directTurns, }); // 状态条的起点只认**未结束**的最新一轮:`awaiting-start`(本地已发出、宿主还没确认) // 也有用户发送时间,只读 `running` 会让卡片在模型首 token 之前根本不出现。 // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮,`awaiting-start` 也只判最新一轮。 const latestTurn = directTurns.at(-1) ?? null; const activeTurnStartedAt = latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0; const statusText = runtimeNotice || statusNotice || (projectPath ? `已打开:${projectNameFromPath(projectPath)}` : '请选择工作区'); useEffect(() => { if (!initialTurn || initialTurn.content.length === 0 || !projectPath) return; if (projectPath !== initialTurn.projectPath) return; // projectId 来自项目清单:清单还没到位时不能先认领,否则首轮需求会被空项目吃掉。 if (!projectId || turnStatus.displayBusy) return; if ( !claimInitialTurnForPage(initialTurn.projectPath, initialTurn.claimScope) ) { return; } const clientTurnId = createDirectProjectTurnId(); const userItem = directCodexUserItemFromContent( initialTurn.content, directCodexConversationMessageId(clientTurnId, 'user'), ); shouldFollowLatestRef.current = true; startInitialTurn({ // 首轮那条用户消息按入口原话展示,引用/附件仍按 canonical content 发给运行时。 messageText: directInitialTurnText(initialTurn.content), clientTurnId, ...(initialTurn.creationType ? { creationType: initialTurn.creationType } : {}), userItem, }); // 首轮需求只由入口 payload、项目身份、清单就绪和回合忙态驱动。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [turnStatus.displayBusy, initialTurn, projectId, projectPath]); useEffect(() => { if (!shouldFollowLatestRef.current) return; const list = messagesRef.current; if (list) list.scrollTop = list.scrollHeight; }, [directEntries, localMessages]); useImperativeHandle(ref, () => ({ announce: (text: string) => { appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() }); }, })); const handleScroll: UIEventHandler = (event) => { const list = event.currentTarget; shouldFollowLatestRef.current = list.scrollHeight - list.scrollTop - list.clientHeight <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD; if (historyHasMore && list.scrollTop <= 24) { void loadEarlierHistory(); } }; return (
setSettingsOpen(true)} onRequestGamePublish={onRequestGamePublish} /> void loadEarlierHistory()} onScroll={handleScroll} /> {pendingConfirmation && onConfirmConfirmation && onCancelConfirmation ? (
{pendingConfirmation.commandId} {pendingConfirmation.detail}
) : null} void cancelTurn()} onReferencePickerOpen={() => { // 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件: // 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。 if (projectPath) void refresh(projectPath); }} onSubmit={submit} onUploadFiles={uploadFiles} />
{settingsOpen ? ( setApprovalOpen(true)} onClose={() => setSettingsOpen(false)} /> ) : null} {settingsOpen && approvalOpen ? ( setApprovalOpen(false)} closeOnEscape={false} /> ) : null}
); }