新增「导出到」全屏面板并复用 DirectProject 对话盒

新增 project-development/export 全屏导出面板:目标大卡、等宽下划线 tab、小红书工作台分栏与第二 subscriber 会话。

面板状态提升到 usePanel 根,tab 页可卸载;输入草稿提升到面板层,切 tab 不丢字。

抽出 DirectProjectChatBox,主聊天与导出会话复用同一套对话盒,不再另写版式。

DirectProjectChatHeader 增加「导出」入口,App.tsx 条件挂载全屏面板。

DirectProjectComposer 增加 referencesEnabled 与 draft/onDraftChange,导出输入区关闭素材引用。

新增 tests/artifactsPanel.test.tsx 覆盖卡片、tab、关闭与对话盒复用。
This commit is contained in:
2026-10-05 11:49:32 +08:00
parent 37cb9fb386
commit df4ef37a1c
24 changed files with 994 additions and 152 deletions
+16
View File
@@ -108,6 +108,7 @@ import {
DirectProjectChatView,
type DirectProjectInitialTurn,
} from './view/project-development/chat/DirectProjectChatView';
import ArtifactsPanel from './view/project-development/export';
import { PlanningChatView } from './view/project-development/planning/PlanningChatView';
import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation';
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
@@ -329,6 +330,8 @@ export function App({
const [publishPackageResult, setPublishPackageResult] =
useState<LocalProjectExportPackageResult | null>(null);
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
// 导出产物:全屏面板由工作台壳按需挂载,关闭即卸载,面板状态不留在壳上。
const [exportPanelOpen, setExportPanelOpen] = useState(false);
const [publishProgress, setPublishProgress] =
useState<GamePublishProgressState | null>(null);
const [publishBlockedMessage, setPublishBlockedMessage] = useState<
@@ -1850,12 +1853,25 @@ export function App({
onRequestGamePublish={
gamePublishAllowed ? requestGamePublish : undefined
}
onRequestExport={
localProject?.projectPath || projectPath
? () => setExportPanelOpen(true)
: undefined
}
onCancelConfirmation={cancelUiCommandConfirmation}
onConfirmConfirmation={confirmUiCommand}
pendingConfirmation={pendingUiConfirmation}
projectPath={localProject?.projectPath ?? projectPath ?? null}
ref={directProjectChatRef}
/>
{exportPanelOpen ? (
<ArtifactsPanel
projectPath={localProject?.projectPath ?? projectPath ?? null}
ensureConversationReadAllowed={ensureDirectHistoryReadAllowed}
ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed}
onClose={() => setExportPanelOpen(false)}
/>
) : null}
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath ?? ''}
@@ -13,15 +13,8 @@ import type { PendingUiConfirmation } from '../../../app/types';
import { projectNameFromPath } from '../../../features/agent-runtime';
import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer';
import { userItemFromContent } from '../../../features/project-workspace/resourceReferences';
import { type ApprovalMode, approvalModeLabel } from '../approvalMode';
import { ApprovalModeDialog } from '../ApprovalModeDialog';
import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader';
import {
DirectProjectComposer,
type DirectProjectComposerHandle,
} from './components/DirectProjectComposer/DirectProjectComposer';
import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation';
import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog';
import { DirectProjectChatBox } from './components/DirectProjectChatBox/DirectProjectChatBox';
import type { DirectProjectComposerHandle } from './components/DirectProjectComposer/DirectProjectComposer';
import {
type DirectProjectConversationReadGate,
type DirectProjectConversationWriteGate,
@@ -84,6 +77,8 @@ export type DirectProjectChatViewProps = {
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
/** 发布入口:由工作台壳持有导出与发布面板,聊天只提供触发按钮。 */
onRequestGamePublish?: () => void;
/** 导出产物入口:由工作台壳持有导出面板,聊天只提供触发按钮。 */
onRequestExport?: () => void;
/**
* 工作台壳入队的项目权限确认:聊天只展示并转发确认/取消,
* 策略读取、拒绝判定和确认后的动作仍由工作台壳持有。
@@ -97,6 +92,7 @@ export type DirectProjectChatViewProps = {
export function DirectProjectChatView({
onRequestGamePublish,
onRequestExport,
onConfirmConfirmation,
onCancelConfirmation,
pendingConfirmation = null,
@@ -110,10 +106,6 @@ export function DirectProjectChatView({
useDirectProjectManifest(projectPath);
const composerHandleRef = useRef<DirectProjectComposerHandle | null>(null);
const [runtimeNotice, setRuntimeNotice] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false);
const [approvalOpen, setApprovalOpen] = useState(false);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalNotice, setApprovalNotice] = useState('');
/** 运行态提示出口:失败说明只展示安全文案,诊断线索留在宿主侧。 */
const handleRuntimeError = useCallback((message: string) => {
setRuntimeNotice(message);
@@ -129,27 +121,14 @@ export function DirectProjectChatView({
});
const {
appendLocalMessage,
attachmentNotice,
attachmentsImporting,
cancelPendingTurn,
cancelTurn,
commandInFlight,
composerNotice,
directEntries,
directTurnRunning,
directTurnStartedAt,
historyError,
historyHasMore,
historyLoading,
loadEarlierHistory,
retryEarlierHistory,
localMessages,
pendingTurns,
startInitialTurn,
statusNotice,
submit,
turnCancelling,
uploadFiles,
} = chat;
// 这份投影要参与下游 memo 的判据,必须自己先缓存:不缓存的话每次渲染都是一份新数组,
// `useDirectProjectTurnStatus` 里以 `turns` 为判据的 `useMemo` 永远命中不了,那层 memo
@@ -241,87 +220,27 @@ export function DirectProjectChatView({
);
return (
<section
className="project-chat-surface is-direct-codex"
aria-label="陶泥儿项目对话"
>
<div className="project-chat-conversation">
<DirectProjectChatHeader
busy={turnStatus.displayBusy}
statusText={statusText}
onOpenSettings={() => setSettingsOpen(true)}
onRequestGamePublish={onRequestGamePublish}
/>
<DirectProjectConversation
conversationKey={projectPath ?? ''}
turns={directTurns}
historyHasMore={historyHasMore}
historyLoading={historyLoading}
historyError={historyError}
turnInFlight={turnStatus.displayBusy}
activeTurnStartedAt={activeTurnStartedAt}
onLoadEarlierHistory={() => void loadEarlierHistory()}
onRetryEarlierHistory={() => void retryEarlierHistory()}
/>
{pendingConfirmation &&
onConfirmConfirmation &&
onCancelConfirmation ? (
<div className="pending-command">
<span>
{pendingConfirmation.commandId}
<small>{pendingConfirmation.detail}</small>
</span>
<button type="button" onClick={onCancelConfirmation}>
取消
</button>
<button type="button" onClick={onConfirmConfirmation}>
确认
</button>
</div>
) : null}
<DirectProjectComposer
ref={composerHandleRef}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath}
attachmentNotice={attachmentNotice}
attachmentsImporting={attachmentsImporting}
pendingTurns={pendingTurns}
composerNotice={composerNotice}
busy={turnStatus.displayBusy}
canStopTurn={turnStatus.canStopTurn}
turnCancelling={turnCancelling}
onCancelPendingTurn={(clientTurnId) =>
void cancelPendingTurn(clientTurnId)
}
onCancelTurn={() => void cancelTurn()}
onReferencePickerOpen={() => {
// 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件:
// 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。
if (projectPath) void refresh(projectPath);
}}
onSubmit={submit}
onUploadFiles={uploadFiles}
/>
</div>
{settingsOpen ? (
<DirectProjectSettingsDialog
projectPath={projectPath ?? ''}
currentApprovalLabel={approvalModeLabel(approvalMode)}
onOpenApproval={() => setApprovalOpen(true)}
onClose={() => setSettingsOpen(false)}
/>
) : null}
{settingsOpen && approvalOpen ? (
<ApprovalModeDialog
approvalMode={approvalMode}
notice={approvalNotice}
onSelect={setApprovalMode}
onNotice={setApprovalNotice}
onClose={() => setApprovalOpen(false)}
closeOnEscape={false}
/>
) : null}
</section>
<DirectProjectChatBox
ariaLabel="陶泥儿项目对话"
controller={chat}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath}
turns={directTurns}
turnStatus={turnStatus}
activeTurnStartedAt={activeTurnStartedAt}
statusText={statusText}
{...(onRequestGamePublish ? { onRequestGamePublish } : {})}
{...(onRequestExport ? { onRequestExport } : {})}
{...(pendingConfirmation ? { pendingConfirmation } : {})}
{...(onConfirmConfirmation ? { onConfirmConfirmation } : {})}
{...(onCancelConfirmation ? { onCancelConfirmation } : {})}
composerRef={composerHandleRef}
onReferencePickerOpen={() => {
// 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件:
// 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。
if (projectPath) void refresh(projectPath);
}}
/>
);
}
@@ -0,0 +1,177 @@
import { type Ref, useState } from 'react';
import type {
GameCreationAppAssetManifestEntry,
GameIterationVersion,
} from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
import type { PendingUiConfirmation } from '../../../../../app/types';
import type { ChatComposerDraft } from '../../../../../features/project-workspace/resourceReferences';
import { type ApprovalMode, approvalModeLabel } from '../../../approvalMode';
import { ApprovalModeDialog } from '../../../ApprovalModeDialog';
import type { useDirectProjectChatController } from '../../controller/useDirectProjectChatController';
import type { useDirectProjectTurnStatus } from '../../controller/useDirectProjectTurnStatus';
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
import type { UserContentPart } from '../../generated/UserContentPart';
import { DirectProjectChatHeader } from '../DirectProjectChatHeader/DirectProjectChatHeader';
import {
DirectProjectComposer,
type DirectProjectComposerHandle,
} from '../DirectProjectComposer/DirectProjectComposer';
import { DirectProjectConversation } from '../DirectProjectConversation/DirectProjectConversation';
import { DirectProjectSettingsDialog } from '../DirectProjectSettingsDialog/DirectProjectSettingsDialog';
type DirectProjectChatController = ReturnType<
typeof useDirectProjectChatController
>;
type DirectProjectTurnStatus = ReturnType<typeof useDirectProjectTurnStatus>;
/**
* DirectProject 对话盒的共用表现层:一个 `.project-chat-surface.is-direct-codex`,
* 里面就是顶栏 + 消息流 + 输入区这**同一套现成组件**。
*
* 主聊天 (`DirectProjectChatView`) 与导出面板(同一 thread 的第二个 subscriber)
* 都渲染这一份,所以两处的样式与交互天然一致;差别只在传进来的 controller、
* 几个入口回调,以及输入区是否启用素材引用。
*
* 会话真相(订阅、回合、待发队列、附件)不在这一层:它属于调用方持有的 controller。
* 这一层只负责把 controller 的投影渲染成原样的对话盒,并持有设置/审批弹层这类
* 纯展示状态。
*
* 注意几何:这套样式挂在 `.game-workbench-chat` 祖先下才成立
* (见 `styles.css` 的 `.game-workbench-chat .project-chat-surface` 链),
* 调用方要把它放进 `<aside className="game-workbench-chat">`。
*/
export type DirectProjectChatBoxProps = {
/** 对话盒语义标签:主聊天与导出会话各给一条。 */
ariaLabel: string;
controller: DirectProjectChatController;
assets: GameCreationAppAssetManifestEntry[];
versions?: GameIterationVersion[];
projectPath: string | null;
turns: DirectChatTurn[];
turnStatus: DirectProjectTurnStatus;
activeTurnStartedAt: number;
statusText: string;
onRequestGamePublish?: () => void;
onRequestExport?: () => void;
pendingConfirmation?: PendingUiConfirmation | null;
onConfirmConfirmation?: () => void;
onCancelConfirmation?: () => void;
/** 输入区句柄出口:主聊天拿它注册「活跃聊天输入区」。 */
composerRef?: Ref<DirectProjectComposerHandle>;
onReferencePickerOpen?: () => void;
referencesEnabled?: boolean;
draft?: UserContentPart[];
onDraftChange?: (draft: ChatComposerDraft) => void;
};
export function DirectProjectChatBox({
ariaLabel,
controller,
assets,
versions,
projectPath,
turns,
turnStatus,
activeTurnStartedAt,
statusText,
onRequestGamePublish,
onRequestExport,
pendingConfirmation = null,
onConfirmConfirmation,
onCancelConfirmation,
composerRef,
onReferencePickerOpen,
referencesEnabled,
draft,
onDraftChange,
}: DirectProjectChatBoxProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const [approvalOpen, setApprovalOpen] = useState(false);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalNotice, setApprovalNotice] = useState('');
return (
<section
className="project-chat-surface is-direct-codex"
aria-label={ariaLabel}
>
<div className="project-chat-conversation">
<DirectProjectChatHeader
busy={turnStatus.displayBusy}
statusText={statusText}
onOpenSettings={() => setSettingsOpen(true)}
{...(onRequestGamePublish ? { onRequestGamePublish } : {})}
{...(onRequestExport ? { onRequestExport } : {})}
/>
<DirectProjectConversation
conversationKey={projectPath ?? ''}
turns={turns}
historyHasMore={controller.historyHasMore}
historyLoading={controller.historyLoading}
historyError={controller.historyError}
turnInFlight={turnStatus.displayBusy}
activeTurnStartedAt={activeTurnStartedAt}
onLoadEarlierHistory={() => void controller.loadEarlierHistory()}
onRetryEarlierHistory={() => void controller.retryEarlierHistory()}
/>
{pendingConfirmation &&
onConfirmConfirmation &&
onCancelConfirmation ? (
<div className="pending-command">
<span>
{pendingConfirmation.commandId}
<small>{pendingConfirmation.detail}</small>
</span>
<button type="button" onClick={onCancelConfirmation}>
取消
</button>
<button type="button" onClick={onConfirmConfirmation}>
确认
</button>
</div>
) : null}
<DirectProjectComposer
ref={composerRef}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath}
attachmentNotice={controller.attachmentNotice}
attachmentsImporting={controller.attachmentsImporting}
pendingTurns={controller.pendingTurns}
composerNotice={controller.composerNotice}
busy={turnStatus.displayBusy}
canStopTurn={turnStatus.canStopTurn}
turnCancelling={controller.turnCancelling}
onCancelPendingTurn={(clientTurnId) =>
void controller.cancelPendingTurn(clientTurnId)
}
onCancelTurn={() => void controller.cancelTurn()}
{...(onReferencePickerOpen ? { onReferencePickerOpen } : {})}
onSubmit={controller.submit}
onUploadFiles={controller.uploadFiles}
{...(referencesEnabled === undefined ? {} : { referencesEnabled })}
{...(draft ? { draft } : {})}
{...(onDraftChange ? { onDraftChange } : {})}
/>
</div>
{settingsOpen ? (
<DirectProjectSettingsDialog
projectPath={projectPath ?? ''}
currentApprovalLabel={approvalModeLabel(approvalMode)}
onOpenApproval={() => setApprovalOpen(true)}
onClose={() => setSettingsOpen(false)}
/>
) : null}
{settingsOpen && approvalOpen ? (
<ApprovalModeDialog
approvalMode={approvalMode}
notice={approvalNotice}
onSelect={setApprovalMode}
onNotice={setApprovalNotice}
onClose={() => setApprovalOpen(false)}
closeOnEscape={false}
/>
) : null}
</section>
);
}
@@ -1,4 +1,4 @@
import { Rocket, Settings } from 'lucide-react';
import { Package, Rocket, Settings } from 'lucide-react';
/**
* DirectProject 聊天头:运行状态与设置入口。
@@ -11,12 +11,15 @@ export function DirectProjectChatHeader({
statusText,
onOpenSettings,
onRequestGamePublish,
onRequestExport,
}: {
busy: boolean;
statusText: string;
onOpenSettings: () => void;
/** 没有项目或不在 Tauri 宿主时不传;传了才渲染发布入口。 */
onRequestGamePublish?: () => void;
/** 没有项目时不传;传了才渲染导出产物入口。 */
onRequestExport?: () => void;
}) {
return (
<header className="project-chat-topbar">
@@ -28,6 +31,18 @@ export function DirectProjectChatHeader({
{busy ? '陶泥儿正在处理' : statusText}
</span>
<div className="project-chat-topbar-actions">
{onRequestExport ? (
<button
type="button"
className="project-chat-publish-trigger"
aria-label="导出产物"
title="导出产物"
onClick={onRequestExport}
>
<Package size={16} aria-hidden="true" />
<span>导出</span>
</button>
) : null}
{onRequestGamePublish ? (
<button
type="button"
@@ -44,6 +44,13 @@ import { pendingTurnChipLabel } from './pendingTurnChipLabel';
type ComposerAttachmentMenuProps = {
disabled: boolean;
/**
* 是否渲染「引用项目素材」入口,默认 `true`。
*
* 导出面板的输入区关掉引用后只剩「上传本地文件」,菜单里不再出现素材选择器,
* 避免一个点开必然空转的入口。
*/
referencePickerEnabled?: boolean;
onPickFiles: (files: readonly File[]) => void;
onOpenReferencePicker: () => void;
};
@@ -51,6 +58,7 @@ type ComposerAttachmentMenuProps = {
/** 左侧 `+`:独立弹层给两条路径——上传本地文件、引用项目素材。 */
export function ComposerAttachmentMenu({
disabled,
referencePickerEnabled = true,
onPickFiles,
onOpenReferencePicker,
}: ComposerAttachmentMenuProps) {
@@ -133,18 +141,20 @@ export function ComposerAttachmentMenu({
<FileUp size={14} aria-hidden="true" />
<span>上传本地文件</span>
</button>
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={() => {
setOpen(false);
onOpenReferencePicker();
}}
>
<Images size={14} aria-hidden="true" />
<span>引用项目素材</span>
</button>
{referencePickerEnabled ? (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={() => {
setOpen(false);
onOpenReferencePicker();
}}
>
<Images size={14} aria-hidden="true" />
<span>引用项目素材</span>
</button>
) : null}
</div>
) : null}
</div>
@@ -31,6 +31,7 @@ import {
} from '../../../../../features/project-workspace/ResourceReferencePicker';
import {
assetsSignature,
type ChatComposerDraft,
type ChatReference,
} from '../../../../../features/project-workspace/resourceReferences';
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
@@ -79,6 +80,9 @@ export function DirectProjectComposer({
onReferencePickerOpen,
onSubmit,
onUploadFiles,
referencesEnabled = true,
draft,
onDraftChange,
ref,
}: {
assets: GameCreationAppAssetManifestEntry[];
@@ -125,6 +129,21 @@ export function DirectProjectComposer({
files: readonly File[],
draftAttachmentCount: number,
) => Promise<DirectCodexTurnAttachment[]>;
/**
* 是否启用素材 / Skill 引用(`@` / `$`)。
*
* 默认 `true`(主聊天)。导出面板那条链路传 `false`:不注入资源 / Skill / 运行区 provider,
* 不渲染 `@` 触发钮与素材选择器,输入区只留文本与附件。
*/
referencesEnabled?: boolean;
/**
* 提升到宿主的输入草稿:Composer 被卸载再挂回来时用它恢复正文。
*
* 导出面板切 tab 会卸载 tab 页,草稿留在面板层(`useXiaohongshuTab`)才不丢字。
*/
draft?: UserContentPart[];
/** 草稿变化回执:宿主持有唯一一份草稿,Composer 只负责投影。 */
onDraftChange?: (draft: ChatComposerDraft) => void;
/**
* 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」,
* 画布的「引用」/拖拽批量引用事件才能落进这份草稿。
@@ -147,22 +166,28 @@ export function DirectProjectComposer({
const skillProvider = useSkillReferenceProvider();
const assetsContentSignature = assetsSignature(assets);
const referenceProviders = useMemo(
() => [
createResourceReferenceProvider({ assets }),
skillProvider,
attachmentReferenceProvider,
runtimeRegionReferenceProvider,
],
() =>
referencesEnabled
? [
createResourceReferenceProvider({ assets }),
skillProvider,
attachmentReferenceProvider,
runtimeRegionReferenceProvider,
]
: // 关掉引用后只留附件 provider:上传的附件芯片仍然要能解析出来,
// 但 `@` / `$` 两类候选菜单不会再被注册。
[attachmentReferenceProvider],
// `assets` 由 manifest 订阅每次渲染重新 `.filter()` 派生,依赖它等于从不命中缓存:
// provider 身份一变,输入区就重新注册 Lexical 更新监听(流式回复时每帧都换)。
// 按内容签名取依赖才真的能跳过重建。
// eslint-disable-next-line react-hooks/exhaustive-deps
[assetsContentSignature, skillProvider],
[assetsContentSignature, referencesEnabled, skillProvider],
);
const openPicker = useCallback(() => {
if (!referencesEnabled) return;
onReferencePickerOpen?.();
pickerRef.current?.open();
}, [onReferencePickerOpen]);
}, [onReferencePickerOpen, referencesEnabled]);
/**
* 导入成功即插芯片:附件只有「正文芯片」这一份事实源,没有第二份待发列表可以回退。
* 失败 / 异常一律不插。导入期间允许多次点击:各自的结果插各自的芯片。
@@ -260,24 +285,31 @@ export function DirectProjectComposer({
projectPath={projectPath ?? ''}
disabled={modelValidating}
rows={3}
placeholder="描述你的想法,或 @ 引用素材"
placeholder={
referencesEnabled ? '描述你的想法,或 @ 引用素材' : '描述你的想法'
}
submitSuppressed={pickerOpen}
containerRef={composerRootRef}
{...(draft ? { initialContent: draft } : {})}
{...(onDraftChange ? { onChange: onDraftChange } : {})}
/>
<div className="project-chat-composer-controls">
<div className="project-chat-composer-controls-left">
<button
type="button"
className="project-chat-reference-trigger"
aria-label="插入素材引用"
title="插入素材引用"
disabled={busy}
onClick={openPicker}
>
<AtSign size={15} aria-hidden="true" />
</button>
{referencesEnabled ? (
<button
type="button"
className="project-chat-reference-trigger"
aria-label="插入素材引用"
title="插入素材引用"
disabled={busy}
onClick={openPicker}
>
<AtSign size={15} aria-hidden="true" />
</button>
) : null}
<ComposerAttachmentMenu
disabled={busy || modelValidating}
referencePickerEnabled={referencesEnabled}
onPickFiles={(files) => void uploadFiles(files)}
onOpenReferencePicker={openPicker}
/>
@@ -312,19 +344,21 @@ export function DirectProjectComposer({
) : null}
{/*
素材选择器由宿主渲染、自己拿数据:确认后只把选中的引用交给输入区句柄,
输入区与它之间只有 `insertReferences` 这一个接缝。
输入区与它之间只有 `insertReferences` 这一个接缝。关掉引用时整块不挂载。
*/}
<ResourceReferencePicker
ref={pickerRef}
anchorRef={composerRootRef}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath ?? ''}
onInsert={(references) =>
composerRef.current?.insertReferences(references)
}
onOpenChange={setPickerOpen}
/>
{referencesEnabled ? (
<ResourceReferencePicker
ref={pickerRef}
anchorRef={composerRootRef}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath ?? ''}
onInsert={(references) =>
composerRef.current?.insertReferences(references)
}
onOpenChange={setPickerOpen}
/>
) : null}
</form>
);
}
@@ -0,0 +1,18 @@
import { type Target, TARGET_META } from '../model';
/**
* 空 tab 页。
*
* 陶泥儿 / TapTap 两个目标当前没有内容,但仍是正式 tab(可选中、可来回切),
* 不是「点了没反应」。正式导出产物接入后替换掉这个占位。
*/
export function EmptyTab({ target }: { target: Target }) {
return (
<div
className="grid h-full min-h-0 place-items-center p-6 text-sm text-[#988476]"
aria-label={`${TARGET_META[target].label}导出页`}
>
{`${TARGET_META[target].label}导出页尚未接入`}
</div>
);
}
@@ -0,0 +1,48 @@
import { X } from 'lucide-react';
import type { ReactNode } from 'react';
/**
* 全屏外壳。
*
* 定位由 `export/index.tsx` 的 portal 容器负责,这一层只管版式与配色。
* 标题、tab 条、关闭按钮**同一行**:`tabs` 从 header 中间传入,没有 tab 时中间留空。
* header 不设纵向内边距,tab 条自己贴住底边,选中指示条正好压在下边框上,像真正的 tab。
*
* 配色全部用 Tailwind 内联值:`--platform-body-fill` 是渐变、不是颜色,写成
* `bg-(--platform-body-fill)` 会解析成空值把背景弄丢,所以这里自带一套暖色梯度。
*/
export function Shell({
children,
tabs,
onClose,
}: {
children: ReactNode;
/** 工作台阶段的 tab 条;目标阶段不传,header 只剩标题与关闭。 */
tabs?: ReactNode;
onClose: () => void;
}) {
return (
<main className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-gradient-to-b from-[#fffdf9] via-[#fdf9f5] to-[#f8efe7] text-[#3d1f10]">
<header className="flex shrink-0 items-stretch gap-3 border-b border-[#e2cbb8] px-4">
<div className="flex items-center py-2">
<strong className="text-base font-semibold">导出到</strong>
</div>
<div className="flex min-w-0 flex-1 items-end justify-center">
{tabs}
</div>
<div className="flex items-center py-2">
<button
type="button"
aria-label="关闭导出面板"
title="关闭"
className="grid size-8 shrink-0 place-items-center rounded-lg border border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] transition hover:border-[#c7653d] hover:text-[#c7653d]"
onClick={onClose}
>
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
</main>
);
}
@@ -0,0 +1,12 @@
import type { PanelState } from '../state/usePanel';
import { TAB_PAGES } from '../tabs/registry';
/**
* 只渲染当前激活 tab 的页面组件。
*
* 非 active 的页面组件会被卸载;它们的状态不在这里,而在面板根的 `usePanel`,
* 所以订阅、回合与草稿都不会随卸载丢失。
*/
export function TabHost({ panel }: { panel: PanelState }) {
return <>{TAB_PAGES[panel.activeTarget](panel)}</>;
}
@@ -0,0 +1,32 @@
import { type Target, TARGET_META } from '../model';
import { TargetIcon } from './TargetIcon';
/**
* 单个导出目标大卡:正方形,**图标是主内容**。
*
* 图标占卡片大部分面积并居中,标题只作下方的注释行;描述留给正式内容接入后再放,
* 现在不抢图标的视觉重量。
*/
export function TargetCard({
target,
onOpen,
}: {
target: Target;
onOpen: (target: Target) => void;
}) {
const meta = TARGET_META[target];
return (
<button
type="button"
className="group flex aspect-square w-full flex-col items-center justify-center gap-4 rounded-3xl border border-[#e2cbb8] bg-[#fffaf5] p-6 text-center shadow-[0_18px_40px_rgba(112,57,30,0.08)] transition hover:-translate-y-0.5 hover:border-[#c7653d] hover:shadow-[0_22px_48px_rgba(112,57,30,0.14)]"
onClick={() => onOpen(target)}
>
<TargetIcon
target={target}
size={200}
className="h-3/5 w-3/5 object-contain transition group-hover:scale-105"
/>
<span className="text-lg font-semibold text-[#3d1f10]">{meta.label}</span>
</button>
);
}
@@ -0,0 +1,19 @@
import { type Target, TARGET_ORDER } from '../model';
import { TargetCard } from './TargetCard';
/**
* 首屏三张目标大卡:`Stage.Targets` 阶段的全部内容。
*
* 卡片是正方形,网格整体在内容槽里居中;窄屏一列、宽屏三列。
*/
export function TargetGrid({ onOpen }: { onOpen: (target: Target) => void }) {
return (
<div className="flex h-full min-h-0 justify-center overflow-auto p-6">
<div className="my-auto grid w-full max-w-5xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
{TARGET_ORDER.map((target) => (
<TargetCard key={target} target={target} onOpen={onOpen} />
))}
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import placeholderIcon from '../../../../../../../packages/shared/src/icons/taonier-product-ip.png';
import type { Target } from '../model';
/**
* 目标图标占位。
*
* 三个目标的正式图标还没到,先统一用陶泥儿 IP 图占位;补齐后在这里按 `target` 分支,
* 卡片与 tab 都不必改。`className` 交给调用方定尺寸(卡片里是主视觉,tab 里只是小标)。
*/
export function TargetIcon({
target,
size = 32,
className,
}: {
target: Target;
size?: number;
className?: string;
}) {
return (
<img
src={placeholderIcon}
alt=""
aria-hidden="true"
width={size}
height={size}
data-target={target}
className={className ?? 'shrink-0 rounded-lg object-contain'}
/>
);
}
@@ -0,0 +1,49 @@
import { type Target, TARGET_META, TARGET_ORDER } from '../model';
import { TargetIcon } from './TargetIcon';
/**
* 进入工作台后由卡片收成的顶部 tab 条:三个目标都是正式 tab,可来回切。
*
* 形态是经典下划线 tab(不是按钮胶囊):小字号、等宽三列、选中项用主色 + 底部指示条,
* 视觉重量压在 header 里不抢标题。等宽靠三列 grid,不随标题长短伸缩。
*/
export function TargetTabs({
activeTarget,
onSelect,
}: {
activeTarget: Target;
onSelect: (target: Target) => void;
}) {
return (
<div
className="mx-auto grid w-full max-w-md grid-cols-3"
role="tablist"
aria-label="导出目标"
>
{TARGET_ORDER.map((target) => {
const active = target === activeTarget;
return (
<button
key={target}
type="button"
role="tab"
aria-selected={active}
className={`relative flex min-w-0 items-center justify-center gap-1.5 px-2 pb-2 pt-1 text-xs font-medium transition-colors after:absolute after:inset-x-3 after:bottom-0 after:h-[2px] after:rounded-full after:transition-colors after:content-[''] ${
active
? 'text-[#c7653d] after:bg-[#c7653d]'
: 'text-[#988476] after:bg-transparent hover:text-[#6f5848]'
}`}
onClick={() => onSelect(target)}
>
<TargetIcon
target={target}
size={20}
className="size-5 shrink-0 object-contain"
/>
<span className="truncate">{TARGET_META[target].label}</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,81 @@
import { createPortal } from 'react-dom';
import type {
DirectProjectConversationReadGate,
DirectProjectConversationWriteGate,
} from '../chat/controller/useDirectProjectChatController';
import { Shell } from './components/Shell';
import { TabHost } from './components/TabHost';
import { TargetGrid } from './components/TargetGrid';
import { TargetTabs } from './components/TargetTabs';
import { Stage } from './model';
import { usePanel } from './state/usePanel';
export type ArtifactsPanelProps = {
/** 绑定的项目路径:订阅、历史读取与回合都在这个项目上。 */
projectPath: string | null;
/**
* 项目对话读 / 写权限门:第二订阅读取历史、以及从这里发送回合时都必经这两道门,
* 由工作台壳注入,面板不自己判定权限。
*/
ensureConversationReadAllowed: DirectProjectConversationReadGate;
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
onClose: () => void;
};
/**
* 导出产物全屏面板。
*
* 打开方式与 `view/ui-editor` 同一种:portal 到 `document.body`、`fixed inset-0`。
* 面板不接 `open` prop——由外层条件渲染,关闭就是卸载,全部状态随组件销毁,
* 生命周期完全本地。
*
* 首屏是三张目标大卡;点进任一目标后卡片收成顶部 tab,下方只渲染当前 tab。
* 每个 tab 的状态都挂在 `usePanel`(面板根)上,页面组件可以随切换卸载而不丢状态。
*/
export default function ArtifactsPanel({
projectPath,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
onClose,
}: ArtifactsPanelProps) {
const panel = usePanel({
projectPath,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
onClose,
});
const content = (
<Shell
onClose={panel.onClose}
tabs={
panel.stage === Stage.Workspace ? (
<TargetTabs
activeTarget={panel.activeTarget}
onSelect={panel.selectTarget}
/>
) : null
}
>
{panel.stage === Stage.Targets ? (
<TargetGrid onOpen={panel.openTarget} />
) : (
<TabHost panel={panel} />
)}
</Shell>
);
if (typeof document === 'undefined') return content;
return createPortal(
// `platform-theme--light` 只为共享组件(工具卡 / 思考块 / 输入区)提供 `--platform-*`
// 变量;本面板自己的版式与配色仍全部走 Tailwind 内联值。
<div className="platform-theme platform-theme--light fixed inset-0 z-50">
<section className="h-full w-full rounded-none p-0 outline-none focus:outline-none">
{content}
</section>
</div>,
document.body,
);
}
@@ -0,0 +1,50 @@
/**
* 导出产物面板的枚举与目标元数据。
*
* 面板内一切「阶段 / 目标」身份都走 `enum`,不用字符串字面量或字符串联合:
* 字符串身份在 tab 注册表、日志和条件分支之间容易被误拼,`enum` 让漏配在类型层就报错。
*/
/** 面板阶段:先选目标大卡,进入工作台后卡片收成顶部 tab。 */
export enum Stage {
Targets,
Workspace,
}
/** 导出目标:tab 身份、注册表键和条件分支都用它。 */
export enum Target {
Taonier,
Xiaohongshu,
TapTap,
}
export type TargetMeta = {
target: Target;
label: string;
description: string;
};
/** 卡片与 tab 的展示顺序:新增目标只改这一处。 */
export const TARGET_ORDER: readonly Target[] = [
Target.Taonier,
Target.Xiaohongshu,
Target.TapTap,
];
export const TARGET_META: Record<Target, TargetMeta> = {
[Target.Taonier]: {
target: Target.Taonier,
label: '陶泥儿',
description: '导出为陶泥儿平台上可在线游玩的游戏',
},
[Target.Xiaohongshu]: {
target: Target.Xiaohongshu,
label: '小红书小工具',
description: '导出为小红书可嵌入的小工具',
},
[Target.TapTap]: {
target: Target.TapTap,
label: 'TapTap H5 小游戏',
description: '导出为 TapTap H5 小游戏产物',
},
};
@@ -0,0 +1,66 @@
import { useCallback, useState } from 'react';
import type {
DirectProjectConversationReadGate,
DirectProjectConversationWriteGate,
} from '../../chat/controller/useDirectProjectChatController';
import { Stage, Target } from '../model';
import {
useXiaohongshuTab,
type XiaohongshuTabState,
} from './useXiaohongshuTab';
export type PanelState = ReturnType<typeof usePanel>;
/**
* 导出产物面板的全部状态。
*
* 面板层只做两件事:持有「阶段 + 当前 tab」,以及把每个 tab 自己的状态 hook 挂在**这一层**上。
* tab 页组件只接收 state 与 action,不自己持状态;切换 tab 时页面组件正常卸载,
* 状态留在面板上,所以订阅、回合与草稿都能跨 tab 存活。
*/
export function usePanel({
projectPath,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
onClose,
}: {
projectPath: string | null;
ensureConversationReadAllowed: DirectProjectConversationReadGate;
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
onClose: () => void;
}) {
const [stage, setStage] = useState<Stage>(Stage.Targets);
const [activeTarget, setActiveTarget] = useState<Target>(Target.Taonier);
/**
* 只要进过一次工作台就一直是 `true`:小红书 tab 的订阅一旦建立就跨 tab 保活,
* 不因为切到空 tab 而退订(退订再订会换一个新的 subscriber 和游标)。
*/
const [workspaceEntered, setWorkspaceEntered] = useState(false);
const openTarget = useCallback((target: Target) => {
setActiveTarget(target);
setStage(Stage.Workspace);
setWorkspaceEntered(true);
}, []);
const selectTarget = useCallback((target: Target) => {
setActiveTarget(target);
}, []);
const xiaohongshu: XiaohongshuTabState = useXiaohongshuTab({
enabled: workspaceEntered && Boolean(projectPath),
projectPath,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
});
return {
stage,
activeTarget,
openTarget,
selectTarget,
onClose,
xiaohongshu,
};
}
@@ -0,0 +1,92 @@
import { useMemo, useState } from 'react';
import {
type DirectProjectConversationReadGate,
type DirectProjectConversationWriteGate,
useDirectProjectChatController,
} from '../../chat/controller/useDirectProjectChatController';
import { useDirectProjectManifest } from '../../chat/controller/useDirectProjectManifest';
import { useDirectProjectTurnStatus } from '../../chat/controller/useDirectProjectTurnStatus';
import { buildDirectChatTurns } from '../../chat/conversation/directTurnPresentation';
import type { UserContentPart } from '../../chat/generated/UserContentPart';
export type XiaohongshuTabState = ReturnType<typeof useXiaohongshuTab>;
/**
* 小红书小工具 tab 的全部状态。
*
* 这个 hook 挂在**面板根**(`usePanel`)上,不挂在 tab 页里:切到别的 tab 时页面组件会卸载,
* 但订阅、回合、历史分页和输入草稿必须留在面板上,切回来时还是同一条会话、同一份草稿。
*
* 订阅走 `useDirectProjectChatController` 内部的 `useDirectThreadChatSubscription`:
* 那是同一 thread 的**第二个 subscriber**,有自己的 `subscriptionId` 和游标,不与主聊天互相推进。
*/
export function useXiaohongshuTab({
enabled,
projectPath,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
}: {
/** 进过一次工作台且项目路径就绪后才订阅:进入前不产生任何后端访问。 */
enabled: boolean;
projectPath: string | null;
ensureConversationReadAllowed: DirectProjectConversationReadGate;
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
}) {
const { assets, projectId, refresh, versions } =
useDirectProjectManifest(projectPath);
const [runtimeNotice, setRuntimeNotice] = useState('');
/**
* 输入草稿提升到面板层:Composer 随 tab 页卸载后,正文不再随组件一起丢。
* 提交成功时 Composer 内部的 `clear()` 会经 `onDraftChange` 把空草稿回执上来。
*/
const [draft, setDraft] = useState<UserContentPart[]>([]);
const chat = useDirectProjectChatController({
enabled,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
onRuntimeError: setRuntimeNotice,
projectId,
projectPath,
refreshManifest: refresh,
});
const turns = useMemo(
() =>
buildDirectChatTurns({
entries: chat.directEntries,
localMessages: chat.localMessages,
turnRunning: chat.directTurnRunning,
// 运行中回合的起点只在这里给:`turn.started.at` 是宿主的时间,条目上要等收口才盖。
turnStartedAt: chat.directTurnStartedAt,
}),
[
chat.directEntries,
chat.localMessages,
chat.directTurnRunning,
chat.directTurnStartedAt,
],
);
const turnStatus = useDirectProjectTurnStatus({
turnRunning: chat.directTurnRunning,
commandInFlight: chat.commandInFlight,
pendingTurns: chat.pendingTurns,
turns,
});
// 运行中回合的读秒起点:只有最新一轮且还没结束才拿实时值,历史回合是 0(不读秒)。
const latestTurn = turns.at(-1) ?? null;
const activeTurnStartedAt =
latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0;
return {
projectPath,
assets,
versions,
chat,
draft,
setDraft,
runtimeNotice,
turns,
turnStatus,
activeTurnStartedAt,
};
}
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import { Target } from '../model';
import type { PanelState } from '../state/usePanel';
import { TaonierPage } from './taonier/Page';
import { TaptapPage } from './taptap/Page';
import { XiaohongshuPage } from './xiaohongshu/Page';
/**
* tab 注册表:`Target` → 该 tab 的渲染函数。
*
* 用 `Record<Target, …>` 收口,新增目标漏配会在类型层报错;每个 tab 要的状态从面板根
* 的 `PanelState` 上取,页面组件本身不持状态。
*/
export const TAB_PAGES: Record<Target, (panel: PanelState) => ReactNode> = {
[Target.Taonier]: () => <TaonierPage />,
[Target.Xiaohongshu]: (panel) => <XiaohongshuPage tab={panel.xiaohongshu} />,
[Target.TapTap]: () => <TaptapPage />,
};
@@ -0,0 +1,7 @@
import { EmptyTab } from '../../components/EmptyTab';
import { Target } from '../../model';
/** 陶泥儿导出 tab:正式产物接入前是空 tab 页。 */
export function TaonierPage() {
return <EmptyTab target={Target.Taonier} />;
}
@@ -0,0 +1,7 @@
import { EmptyTab } from '../../components/EmptyTab';
import { Target } from '../../model';
/** TapTap H5 小游戏导出 tab:正式产物接入前是空 tab 页。 */
export function TaptapPage() {
return <EmptyTab target={Target.TapTap} />;
}
@@ -0,0 +1,13 @@
/**
* 小红书工作台左半。
*
* 按需求先留白:后续导出内容落在这里,当前不渲染任何内容,只占住分栏。
*/
export function ArtifactsPane() {
return (
<div
className="min-h-0 border-[#e2cbb8] bg-[#fffdf9]/40 md:border-r"
aria-label="导出内容区"
/>
);
}
@@ -0,0 +1,18 @@
import type { XiaohongshuTabState } from '../../state/useXiaohongshuTab';
import { ArtifactsPane } from './ArtifactsPane';
import { ChatPane } from './chat/Pane';
/**
* 小红书小工具工作台:中缝一分为二,左半留白,右半是另一个 chatbox。
*
* 右半的会话状态不在这里,而在面板根的 `useXiaohongshuTab`:这一页跟着 tab 切走时可以卸载,
* 订阅、回合和草稿都留在面板上。
*/
export function XiaohongshuPage({ tab }: { tab: XiaohongshuTabState }) {
return (
<div className="grid h-full min-h-0 min-w-0 grid-cols-1 overflow-hidden md:grid-cols-2">
<ArtifactsPane />
<ChatPane tab={tab} />
</div>
);
}
@@ -0,0 +1,39 @@
import { DirectProjectChatBox } from '../../../../chat/components/DirectProjectChatBox/DirectProjectChatBox';
import type { XiaohongshuTabState } from '../../../state/useXiaohongshuTab';
/**
* 右半 chatbox:**原样复用**主聊天的对话盒(`DirectProjectChatBox`)。
*
* 外壳用 `.game-workbench-chat`:对话盒那套 `.project-chat-surface.is-direct-codex` 几何
* 是按这个祖先写的,换成自造容器就会串版。会话真相不在这里——订阅、回合与草稿都在面板根的
* `useXiaohongshuTab` 上,这里只是把第二个 subscriber 的投影喂给同一个对话盒。
*
* 与主聊天的差别只有一处:输入区关掉素材引用(`referencesEnabled={false}`),
* 草稿提升到面板层,Composer 随 tab 页卸载再挂回来也不丢字。
*/
export function ChatPane({ tab }: { tab: XiaohongshuTabState }) {
const { chat, turnStatus } = tab;
return (
<aside
className="game-workbench-chat h-full min-h-0"
aria-label="小红书小工具导出对话栏"
>
<DirectProjectChatBox
ariaLabel="小红书小工具导出对话"
controller={chat}
assets={tab.assets}
{...(tab.versions ? { versions: tab.versions } : {})}
projectPath={tab.projectPath}
turns={tab.turns}
turnStatus={turnStatus}
activeTurnStartedAt={tab.activeTurnStartedAt}
statusText={
tab.runtimeNotice || chat.statusNotice || '小红书小工具导出会话'
}
referencesEnabled={false}
draft={tab.draft}
onDraftChange={(next) => tab.setDraft(next.content)}
/>
</aside>
);
}
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
/**
* 导出产物面板:卡片 → tab 的入口形态与关闭出口。
*
* 面板状态(阶段 / 当前 tab)在面板根上,本用例只钉住「大卡进入 → 卡片收成 tab → 空 tab 页」
* 这条可见链路,不覆盖右侧 chatbox 的订阅行为。
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import ArtifactsPanel from '../src/view/project-development/export';
afterEach(() => {
cleanup();
});
function renderPanel() {
const onClose = vi.fn();
render(
<ArtifactsPanel
projectPath={null}
ensureConversationReadAllowed={async () => true}
ensureConversationWriteAllowed={async () => true}
onClose={onClose}
/>,
);
return { onClose };
}
describe('导出产物面板', () => {
it('首屏用大卡片展示三个导出目标', () => {
renderPanel();
expect(screen.getByText('陶泥儿')).not.toBeNull();
expect(screen.getByText('小红书小工具')).not.toBeNull();
expect(screen.getByText('TapTap H5 小游戏')).not.toBeNull();
});
it('点进目标后卡片收成顶部 tab,切到空目标只换 tab 页', () => {
renderPanel();
fireEvent.click(screen.getByRole('button', { name: /陶泥儿/u }));
expect(screen.getByRole('tablist', { name: '导出目标' })).not.toBeNull();
expect(screen.getByText('陶泥儿导出页尚未接入')).not.toBeNull();
fireEvent.click(screen.getByRole('tab', { name: /TapTap H5 小游戏/u }));
expect(screen.getByText('TapTap H5 小游戏导出页尚未接入')).not.toBeNull();
});
it('小红书 tab 挂载的是主聊天同一个对话盒', () => {
renderPanel();
fireEvent.click(screen.getByRole('button', { name: /小红书小工具/u }));
const surface = document.querySelector(
'.game-workbench-chat .project-chat-surface.is-direct-codex',
);
expect(surface).not.toBeNull();
expect(surface?.getAttribute('aria-label')).toBe('小红书小工具导出对话');
// 对话盒里就是主聊天那套「顶栏 / 消息流 / 输入区」,不是另一份自造版式。
expect(surface?.querySelector('.project-chat-conversation')).not.toBeNull();
expect(surface?.querySelector('.project-chat-composer')).not.toBeNull();
});
it('关闭按钮把销毁交给壳层', () => {
const { onClose } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '关闭导出面板' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});