右侧对话面板改为 Codex 桌面客户端界面语言
- ProjectSupervisorView:direct-codex 面板改成三段式(顶部极简条 / 唯一滚动区消息列表 / 文档流输入盒) - ProjectSupervisorView:顶部只留状态点 + 状态文案(aria-live=polite)+ 设置齿轮,移除头像、标题、副标题、审批按钮与泥点钱包槽 - ProjectSupervisorView:新增 direct-codex 空态(线稿图标 + 主标题「你想让陶泥儿做什么游戏?」+ 短指令副标题),整体在消息区垂直水平居中 - ProjectSupervisorView:输入盒改成三行结构(`项目名` · `本地` 信息标签 / ResourceReferenceInput 文本区 / 控制排) - ProjectSupervisorView:控制排左为 `+` 附件钮与 `@` 引用钮(都复用 composerRef.openPicker),右为模型下拉与圆形主色发送钮 - ProjectSupervisorView:新增 walletEntry 可选属性,透传到设置浮层的「泥点」行 - ProjectSupervisorSettingsDialog(新增):设置齿轮打开的独立浮层(role=dialog + aria-modal + Esc 关闭 + 点遮罩关闭),承载运行配置入口、操作权限入口与泥点 - approvalMode(新增):把「严格 / 风险 / 无」审批模式选项与文案抽成可复用模块 - ApprovalModeDialog(新增):审批模式对话框改成可复用组件,index.tsx 改为复用它 - project-development/index.tsx:`!uiEditorRoute` 的 game-workbench-chat 头部精简掉标题/审批按钮/钱包槽,并用 cloneElement 把 walletEntry 透传给 supervisor 元素 - project-development/index.tsx:`planningStartMode` 分支保持原样 - ConversationModelSelect:触发按钮只显示已选模型 displayName,未加载完时显示「模型」占位(菜单内加载提示保留) - styles.css:消息列表去掉绝对定位与 196px/256px 底边留白,改成唯一滚动区 - styles.css:输入盒去掉绝对定位,改成文档流三行网格,成为唯一有边框的容器(1px 边框、14px 圆角) - styles.css:助手消息去掉整块底色,用户消息保留右对齐浅底气泡 + 主色描边 - styles.css:新增顶栏、空态、设置浮层与暖色执行过程卡的样式区块,颜色全部取自现有 platform 变量 - styles.css:窄屏(760px/1000px 断点)下输入盒留在文档流并保留四周边距 - chatDialogFrameLayout.test.ts:把「输入区浮在消息列表之上」的旧契约改写成「输入盒在消息列表下方、文档流、列表无浮层留白」的新契约 - project-development.suite.ts:同步更新表单布局、提交按钮尺寸与钱包槽的断言,并新增空态 + 设置浮层用例
This commit is contained in:
+2
-2
@@ -331,8 +331,8 @@ export function ConversationModelSelect({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="conversation-model-trigger-label">
|
||||
{models.find((model) => model.id === selected)?.displayName ??
|
||||
(busy ? '正在读取模型' : '选择模型')}
|
||||
{/* 控件位只显示已选模型名;目录还没读完时用「模型」占位(加载提示留在菜单里)。 */}
|
||||
{models.find((model) => model.id === selected)?.displayName ?? '模型'}
|
||||
</span>
|
||||
<ChevronDown size={13} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { Settings2, ShieldCheck, Wallet, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
closeDialogOnBackdropMouseDown,
|
||||
useEscapeToClose,
|
||||
} from '../../app/dialogs';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
|
||||
/**
|
||||
* 右侧对话面板 Codex 风格改造后,面板顶部只剩「状态点 + 状态文案 + 齿轮」。
|
||||
* 原来长在面板顶部(以及 `view/project-development/index.tsx` 头部)的
|
||||
* 运行配置 / 审批配置 / 泥点钱包统一收进这个独立浮层:点齿轮才出现,
|
||||
* 不再往面板下面追加内容。
|
||||
*
|
||||
* 组成:
|
||||
* - 「运行配置」打开既有 `RuntimeConfigDialog`(独立浮层,自己有 backdrop);
|
||||
* - 「操作权限」打开由 `ApprovalModeDialog` 提供的选择面板;
|
||||
* - 「泥点」直接渲染父级传入的 `walletEntry`(为空则整行不渲染)。
|
||||
*/
|
||||
export function ProjectSupervisorSettingsDialog({
|
||||
projectPath,
|
||||
currentApprovalLabel,
|
||||
onOpenApproval,
|
||||
walletEntry,
|
||||
onClose,
|
||||
}: {
|
||||
projectPath: string;
|
||||
currentApprovalLabel: string;
|
||||
onOpenApproval: () => void;
|
||||
walletEntry?: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
|
||||
// 二级浮层自己处理 Esc;否则一次 Esc 会把两层一起关掉。
|
||||
useEscapeToClose(onClose, !runtimeConfigOpen);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="project-supervisor-settings-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => closeDialogOnBackdropMouseDown(event, onClose)}
|
||||
>
|
||||
<section
|
||||
className="project-supervisor-settings-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="project-supervisor-settings-title"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="project-supervisor-settings-title">对话设置</h2>
|
||||
<small>运行配置、操作权限与泥点</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="project-supervisor-settings-close"
|
||||
aria-label="关闭设置"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="project-supervisor-settings-rows">
|
||||
<button
|
||||
type="button"
|
||||
className="project-supervisor-settings-row"
|
||||
onClick={() => setRuntimeConfigOpen(true)}
|
||||
>
|
||||
<span className="project-supervisor-settings-row-main">
|
||||
<Settings2 size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>运行配置</strong>
|
||||
<small>模型、Agent 分工与高级参数</small>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="project-supervisor-settings-row"
|
||||
aria-label={`审批配置,当前${currentApprovalLabel}`}
|
||||
onClick={onOpenApproval}
|
||||
>
|
||||
<span className="project-supervisor-settings-row-main">
|
||||
<ShieldCheck size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>操作权限</strong>
|
||||
<small>{currentApprovalLabel}</small>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{walletEntry ? (
|
||||
<div className="project-supervisor-settings-row is-static">
|
||||
<span className="project-supervisor-settings-row-main">
|
||||
<Wallet size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>泥点</strong>
|
||||
<small>当前余额与充值入口</small>
|
||||
</span>
|
||||
</span>
|
||||
<div className="game-workbench-chat-wallet">{walletEntry}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{runtimeConfigOpen ? (
|
||||
<RuntimeConfigDialog
|
||||
projectPath={projectPath}
|
||||
onClose={() => setRuntimeConfigOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+116
-4
@@ -1,7 +1,15 @@
|
||||
import { ArrowUp, AtSign, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
Loader2,
|
||||
MessageSquareDashed,
|
||||
Plus,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
FormEventHandler,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
UIEventHandler,
|
||||
} from 'react';
|
||||
@@ -18,6 +26,11 @@ import type {
|
||||
} from '../../app/types';
|
||||
import type { DesignClarificationRequest, DesignView } from '../../app/types';
|
||||
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
|
||||
import {
|
||||
type ApprovalMode,
|
||||
approvalModeLabel,
|
||||
} from '../../view/project-development/approvalMode';
|
||||
import { ApprovalModeDialog } from '../../view/project-development/ApprovalModeDialog';
|
||||
import {
|
||||
projectProfessionalAgentLabel,
|
||||
projectRuntimeVisibleError,
|
||||
@@ -43,6 +56,7 @@ import {
|
||||
import { isPlanningLaneRuntime } from './planningLane';
|
||||
import { PlanningLaneRuntimeStrip } from './PlanningLaneRuntimeStrip';
|
||||
import { resolvePendingCommandProjectPath } from './projectCommandPolicy';
|
||||
import { ProjectSupervisorSettingsDialog } from './ProjectSupervisorSettingsDialog';
|
||||
import {
|
||||
ResourceReferenceInput,
|
||||
type ResourceReferenceInputHandle,
|
||||
@@ -70,6 +84,12 @@ function directStatusTitle(status: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Codex 顶栏只显示项目名:取路径最后一段,拿不到时退回占位文案。 */
|
||||
function projectDisplayName(projectPath: string) {
|
||||
const name = projectPath.split(/[\\/]/u).filter(Boolean).pop();
|
||||
return name ?? '未选择项目';
|
||||
}
|
||||
|
||||
type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
activeVersionId?: string | null;
|
||||
chatInput: string;
|
||||
@@ -99,6 +119,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
designReasoning?: string;
|
||||
visibleMessages: ChatMessage[];
|
||||
visibleProfessionalAgentCards: AgentStatusCard[];
|
||||
walletEntry?: ReactNode;
|
||||
workspaceStatus: string;
|
||||
planGddState: PlanGddStateViewV1 | null;
|
||||
planGddHydrateBusy: boolean;
|
||||
@@ -151,6 +172,7 @@ export function ProjectSupervisorView({
|
||||
designReasoning = '',
|
||||
visibleMessages,
|
||||
visibleProfessionalAgentCards,
|
||||
walletEntry,
|
||||
workspaceStatus,
|
||||
planGddState,
|
||||
planGddHydrateBusy,
|
||||
@@ -187,6 +209,27 @@ export function ProjectSupervisorView({
|
||||
const [modelValidating, setModelValidating] = useState(false);
|
||||
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
|
||||
const modelValidateInFlightRef = useRef(false);
|
||||
// 设置浮层:Codex 顶栏只剩状态与齿轮,运行配置 / 审批模式 / 钱包都收进这里。
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [approvalOpen, setApprovalOpen] = useState(false);
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
|
||||
const [approvalNotice, setApprovalNotice] = useState('');
|
||||
useEffect(() => {
|
||||
setApprovalOpen(false);
|
||||
setApprovalNotice('');
|
||||
}, [settingsOpen]);
|
||||
const runBusy =
|
||||
runtimePanelProps.controlBusy || Boolean(directProcessDetail) || submitting;
|
||||
const emptyState =
|
||||
directCodex &&
|
||||
visibleMessages.length === 0 &&
|
||||
!transientReply &&
|
||||
!directProcessDetail &&
|
||||
!runtimePanelProps.controlBusy &&
|
||||
!pendingCommand &&
|
||||
!pendingConfirmation &&
|
||||
!designView?.session.pendingApproval &&
|
||||
!designView?.session.pendingClarification;
|
||||
const submitButton = (
|
||||
<button
|
||||
type="submit"
|
||||
@@ -236,12 +279,44 @@ export function ProjectSupervisorView({
|
||||
onMakeGame={onMakeGameFromApprovedGdd}
|
||||
/>
|
||||
)}
|
||||
{directCodex ? (
|
||||
<header className="project-supervisor-topbar">
|
||||
<span
|
||||
className="project-supervisor-topbar-status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span
|
||||
className={`project-supervisor-topbar-dot${runBusy ? ' is-busy' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{runBusy
|
||||
? directStatusTitle(directStatus)
|
||||
: projectWorkspaceStatusForDisplay(workspaceStatus)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="project-supervisor-settings-trigger"
|
||||
aria-label="设置"
|
||||
title="设置"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
) : null}
|
||||
<div
|
||||
ref={messagesRef}
|
||||
className="message-list project-supervisor-message-list"
|
||||
aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{emptyState ? (
|
||||
<div className="project-supervisor-empty-state">
|
||||
<MessageSquareDashed size={30} aria-hidden="true" />
|
||||
<strong>你想让陶泥儿做什么游戏?</strong>
|
||||
<small>告诉陶泥儿接下来要做什么,或输入 @ 选择资源</small>
|
||||
</div>
|
||||
) : null}
|
||||
{hiddenConversationCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -433,6 +508,12 @@ export function ProjectSupervisorView({
|
||||
void validateModel();
|
||||
}}
|
||||
>
|
||||
{directCodex ? (
|
||||
<div className="project-supervisor-composer-context">
|
||||
<span title={projectPath}>{projectDisplayName(projectPath)}</span>
|
||||
<span>本地</span>
|
||||
</div>
|
||||
) : null}
|
||||
<ResourceReferenceInput
|
||||
ref={composerRef}
|
||||
ariaLabel={directCodex ? '陶泥儿对话内容' : '项目需求'}
|
||||
@@ -461,6 +542,16 @@ export function ProjectSupervisorView({
|
||||
{directCodex ? (
|
||||
<div className="project-supervisor-composer-controls">
|
||||
<div className="project-supervisor-composer-controls-left">
|
||||
<button
|
||||
type="button"
|
||||
className="project-supervisor-attachment-trigger"
|
||||
aria-label="添加素材引用"
|
||||
title="添加素材引用"
|
||||
disabled={runtimePanelProps.controlBusy || needsUserInput}
|
||||
onClick={() => composerRef?.current?.openPicker()}
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="project-supervisor-reference-trigger"
|
||||
@@ -488,9 +579,11 @@ export function ProjectSupervisorView({
|
||||
submitButton
|
||||
)}
|
||||
</form>
|
||||
<small className="project-supervisor-workspace-status">
|
||||
{projectWorkspaceStatusForDisplay(workspaceStatus)}
|
||||
</small>
|
||||
{directCodex ? null : (
|
||||
<small className="project-supervisor-workspace-status">
|
||||
{projectWorkspaceStatusForDisplay(workspaceStatus)}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{showProfessionalCollaboration ? (
|
||||
<aside
|
||||
@@ -530,6 +623,25 @@ export function ProjectSupervisorView({
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
{directCodex && settingsOpen ? (
|
||||
<ProjectSupervisorSettingsDialog
|
||||
projectPath={projectPath}
|
||||
currentApprovalLabel={approvalModeLabel(approvalMode)}
|
||||
onOpenApproval={() => setApprovalOpen(true)}
|
||||
walletEntry={walletEntry}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{directCodex && settingsOpen && approvalOpen ? (
|
||||
<ApprovalModeDialog
|
||||
approvalMode={approvalMode}
|
||||
notice={approvalNotice}
|
||||
onSelect={setApprovalMode}
|
||||
onNotice={setApprovalNotice}
|
||||
onClose={() => setApprovalOpen(false)}
|
||||
closeOnEscape={false}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
closeDialogOnBackdropMouseDown,
|
||||
useEscapeToClose,
|
||||
} from '../../app/dialogs';
|
||||
import { type ApprovalMode, approvalModeOptions } from './approvalMode';
|
||||
|
||||
type ApprovalModeDialogProps = {
|
||||
approvalMode: ApprovalMode;
|
||||
notice: string;
|
||||
onSelect: (mode: ApprovalMode) => void;
|
||||
onNotice?: (notice: string) => void;
|
||||
onClose: () => void;
|
||||
/** 关掉 Esc 内部监听:嵌在设置浮层里时由外层浮层统一处理 Esc。 */
|
||||
closeOnEscape?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 「陶泥儿的操作权限」对话框。选项定义在 `./approvalMode`,本文件只导出组件
|
||||
* (`react-refresh/only-export-components`)。
|
||||
*/
|
||||
export function ApprovalModeDialog({
|
||||
approvalMode,
|
||||
notice,
|
||||
onSelect,
|
||||
onNotice,
|
||||
onClose,
|
||||
closeOnEscape = false,
|
||||
}: ApprovalModeDialogProps) {
|
||||
useEscapeToClose(onClose, closeOnEscape);
|
||||
return (
|
||||
<div
|
||||
className="game-approval-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => closeDialogOnBackdropMouseDown(event, onClose)}
|
||||
>
|
||||
<section
|
||||
className="game-approval-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="game-approval-title"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="game-approval-title">陶泥儿的操作权限</h2>
|
||||
<p>P0 仅开放严格审批</p>
|
||||
</div>
|
||||
<button type="button" aria-label="关闭审批配置" onClick={onClose}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="game-approval-options" role="radiogroup">
|
||||
{approvalModeOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={approvalMode === option.id}
|
||||
data-unavailable={!option.available || undefined}
|
||||
className={`${approvalMode === option.id ? 'is-selected' : ''}${
|
||||
option.available ? '' : ' is-unavailable'
|
||||
}`}
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
if (!option.available) {
|
||||
onNotice?.(option.detail);
|
||||
return;
|
||||
}
|
||||
onSelect(option.id);
|
||||
onNotice?.('');
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
<small>{option.detail}</small>
|
||||
</span>
|
||||
<span className="game-approval-check" aria-hidden="true">
|
||||
{approvalMode === option.id ? '✓' : ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notice ? (
|
||||
<p className="game-approval-notice" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
<button type="button" className="game-approval-done" onClick={onClose}>
|
||||
完成
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 陶泥儿的操作权限(审批模式)选项与文案。
|
||||
*
|
||||
* 原本只长在 `view/project-development/index.tsx` 里;右侧对话面板 Codex 风格改造后,
|
||||
* 面板顶部不再直接挂「审批配置」按钮,审批模式改从设置浮层进入,于是把选项搬到这里,
|
||||
* 由 index.tsx 与 `ProjectSupervisorSettingsDialog` 共用同一份定义,避免两处各写一份
|
||||
* 「严格 / 风险 / 无」。
|
||||
*/
|
||||
export type ApprovalMode = 'strict' | 'risk' | 'none';
|
||||
|
||||
export type ApprovalModeOption = {
|
||||
id: ApprovalMode;
|
||||
label: string;
|
||||
detail: string;
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
export const approvalModeOptions: ApprovalModeOption[] = [
|
||||
{
|
||||
id: 'strict',
|
||||
label: '严格审批',
|
||||
detail: '所有消耗泥点的生成事务与 Agent 请求均需确认',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'risk',
|
||||
label: '风险审批',
|
||||
detail: 'Rank 规则待定,当前暂不可用',
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
label: '无需审批',
|
||||
detail: 'Runtime 安全合同尚未完成,当前暂不可用',
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function approvalModeLabel(mode: ApprovalMode) {
|
||||
return (
|
||||
approvalModeOptions.find((option) => option.id === mode)?.label ??
|
||||
'严格审批'
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
Replace,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
@@ -48,10 +47,13 @@ import {
|
||||
ZoomOut,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
cloneElement,
|
||||
type CSSProperties,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
memo,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
startTransition,
|
||||
useCallback,
|
||||
@@ -197,6 +199,8 @@ import {
|
||||
} from '../../services/platformSession';
|
||||
import UiEditorPage from '../ui-editor';
|
||||
import type { UiEditorStepId } from '../ui-editor/model';
|
||||
import type { ApprovalMode } from './approvalMode';
|
||||
import { ApprovalModeDialog } from './ApprovalModeDialog';
|
||||
import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage';
|
||||
import {
|
||||
type ProjectManifestSnapshotMetadata,
|
||||
@@ -338,7 +342,6 @@ export type {
|
||||
type ResourceCategory = ProjectResourceCategory;
|
||||
type ResourceSortMode = ProjectResourceCanvasLayoutMode;
|
||||
type WorkbenchMode = 'resources' | 'run';
|
||||
type ApprovalMode = 'strict' | 'risk' | 'none';
|
||||
type OrchestrationMode = 'single-supervisor' | 'professional-dag';
|
||||
|
||||
function isPlatformAuthenticationRequired(error: unknown) {
|
||||
@@ -614,32 +617,6 @@ const categoryIcons: Record<ResourceBookTarget, typeof FileText> = {
|
||||
[RESOURCE_BOOK_ALL_TARGET]: Layers,
|
||||
};
|
||||
|
||||
const approvalOptions: Array<{
|
||||
id: ApprovalMode;
|
||||
label: string;
|
||||
detail: string;
|
||||
available: boolean;
|
||||
}> = [
|
||||
{
|
||||
id: 'strict',
|
||||
label: '严格审批',
|
||||
detail: '所有消耗泥点的生成事务与 Agent 请求均需确认',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'risk',
|
||||
label: '风险审批',
|
||||
detail: 'Rank 规则待定,当前暂不可用',
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
label: '无需审批',
|
||||
detail: 'Runtime 安全合同尚未完成,当前暂不可用',
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
function summarizeAgent(
|
||||
manifest: GameCreationAppManifest,
|
||||
group: AgentSummary['group'],
|
||||
@@ -3114,9 +3091,17 @@ export default function ProjectDevelopmentView({
|
||||
const agentSummaries = showAllAgentGroups
|
||||
? allAgentSummaries
|
||||
: allAgentSummaries.slice(0, 3);
|
||||
const currentApprovalLabel =
|
||||
approvalOptions.find((option) => option.id === approvalMode)?.label ??
|
||||
'严格审批';
|
||||
/**
|
||||
* 会话面板 Codex 风格改造后,面板顶部不再直接挂泥点钱包,钱包收进面板自己的设置浮层。
|
||||
* `supervisor` 是外部传进来的 React 元素(`WorkspaceLauncher` 里的 `ProjectSupervisor`),
|
||||
* 这里用 `cloneElement` 把 `walletEntry` 补进去;元素形态不变时原样返回,不改变既有行为。
|
||||
*/
|
||||
const supervisorSurface =
|
||||
walletEntry && isValidElement(supervisor)
|
||||
? cloneElement(supervisor as ReactElement<{ walletEntry?: ReactNode }>, {
|
||||
walletEntry,
|
||||
})
|
||||
: supervisor;
|
||||
|
||||
const resourceSectionScrollKey = useCallback(
|
||||
(category: ResourceCategory, layoutMode = sortMode) =>
|
||||
@@ -7942,29 +7927,7 @@ export default function ProjectDevelopmentView({
|
||||
|
||||
{!uiEditorRoute ? (
|
||||
<aside className="game-workbench-chat" aria-label="陶泥儿 Agent 对话">
|
||||
<header>
|
||||
<div className="game-workbench-chat-title">
|
||||
<strong>与陶泥儿的对话</strong>
|
||||
<small>
|
||||
{professionalDagVisible ? '项目总控 Agent' : '智能创作'}
|
||||
</small>
|
||||
</div>
|
||||
{walletEntry ? (
|
||||
<div className="game-workbench-chat-wallet">{walletEntry}</div>
|
||||
) : null}
|
||||
{professionalDagVisible ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-approval-trigger"
|
||||
onClick={() => setApprovalDialogOpen(true)}
|
||||
aria-label={`审批配置,当前${currentApprovalLabel}`}
|
||||
>
|
||||
<Settings2 size={14} aria-hidden="true" />
|
||||
{currentApprovalLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
{supervisor}
|
||||
{supervisorSurface}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -8277,79 +8240,14 @@ export default function ProjectDevelopmentView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{professionalDagVisible && approvalDialogOpen ? (
|
||||
<div
|
||||
className="game-approval-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setApprovalDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className="game-approval-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="game-approval-title"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="game-approval-title">陶泥儿的操作权限</h2>
|
||||
<p>P0 仅开放严格审批</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭审批配置"
|
||||
onClick={() => setApprovalDialogOpen(false)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="game-approval-options" role="radiogroup">
|
||||
{approvalOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={approvalMode === option.id}
|
||||
data-unavailable={!option.available || undefined}
|
||||
className={`${approvalMode === option.id ? 'is-selected' : ''}${
|
||||
option.available ? '' : ' is-unavailable'
|
||||
}`}
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
if (!option.available) {
|
||||
setApprovalNotice(option.detail);
|
||||
return;
|
||||
}
|
||||
setApprovalMode(option.id);
|
||||
setApprovalNotice('');
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
<small>{option.detail}</small>
|
||||
</span>
|
||||
<span className="game-approval-check" aria-hidden="true">
|
||||
{approvalMode === option.id ? '✓' : ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{approvalNotice ? (
|
||||
<p className="game-approval-notice" role="status">
|
||||
{approvalNotice}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="game-approval-done"
|
||||
onClick={() => setApprovalDialogOpen(false)}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
{approvalDialogOpen ? (
|
||||
<ApprovalModeDialog
|
||||
approvalMode={approvalMode}
|
||||
notice={approvalNotice}
|
||||
onSelect={setApprovalMode}
|
||||
onNotice={setApprovalNotice}
|
||||
onClose={() => setApprovalDialogOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createRef } from 'react';
|
||||
|
||||
import type {
|
||||
ProjectResourceCanvasLayout,
|
||||
ProjectResourceCanvasPosition,
|
||||
} from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { ProjectSupervisorView } from '../../src/features/project-workspace/ProjectSupervisorView';
|
||||
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
|
||||
import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog';
|
||||
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
|
||||
import {
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
composerValue,
|
||||
createGameCreationAppManifest,
|
||||
createGameCreationAppSeedTasks,
|
||||
createPlanGddStateView,
|
||||
createProjectSupervisorRuntimeHarness,
|
||||
emptyProjectPolicy,
|
||||
expect,
|
||||
@@ -494,25 +498,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(screen.getByText('broken-reference.png')).not.toBeNull();
|
||||
expect(screen.getByText('图片解码失败')).not.toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '审批配置,当前严格审批',
|
||||
}),
|
||||
);
|
||||
// 面板顶部已按 Codex 风格精简:审批入口不再长在会话列头部,改从面板自己的设置浮层进入,
|
||||
// 所以这里不该再出现「审批配置」按钮,也不该出现审批对话框。
|
||||
expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '陶泥儿的操作权限' }),
|
||||
).not.toBeNull();
|
||||
const riskApproval = screen.getByRole('radio', { name: /风险审批/ });
|
||||
fireEvent.click(riskApproval);
|
||||
expect(riskApproval.getAttribute('aria-checked')).toBe('false');
|
||||
expect(riskApproval.getAttribute('data-unavailable')).toBe('true');
|
||||
expect(screen.getAllByText('Rank 规则待定,当前暂不可用')).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '审批配置,当前严格审批',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
screen.queryByRole('dialog', { name: '陶泥儿的操作权限' }),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '更多小组' }));
|
||||
expect(screen.getByRole('article', { name: /数值 Agent/ })).not.toBeNull();
|
||||
@@ -520,6 +511,38 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the approval-mode choices reachable in their own dialog surface', () => {
|
||||
// 审批模式原来是长在会话列头部按钮里的对话框;面板顶部精简之后它由设置浮层里的
|
||||
// 「操作权限」行打开,但选项与「不可用项给出说明」的语义必须完整保留。
|
||||
function ApprovalDialogHost() {
|
||||
const [mode, setMode] = React.useState<'strict' | 'risk' | 'none'>(
|
||||
'strict',
|
||||
);
|
||||
const [notice, setNotice] = React.useState('');
|
||||
return React.createElement(ApprovalModeDialog, {
|
||||
approvalMode: mode,
|
||||
notice,
|
||||
onSelect: setMode,
|
||||
onNotice: setNotice,
|
||||
onClose: () => undefined,
|
||||
});
|
||||
}
|
||||
render(React.createElement(ApprovalDialogHost));
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '陶泥儿的操作权限' }),
|
||||
).not.toBeNull();
|
||||
const strictApproval = screen.getByRole('radio', { name: /严格审批/ });
|
||||
expect(strictApproval.getAttribute('aria-checked')).toBe('true');
|
||||
const riskApproval = screen.getByRole('radio', { name: /风险审批/ });
|
||||
expect(riskApproval.getAttribute('data-unavailable')).toBe('true');
|
||||
fireEvent.click(riskApproval);
|
||||
// 不可用项不改变选中态,而是给出原因(选项里那份 + 说明那一行,共两处)。
|
||||
expect(riskApproval.getAttribute('aria-checked')).toBe('false');
|
||||
expect(screen.getAllByText('Rank 规则待定,当前暂不可用')).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭审批配置' }));
|
||||
});
|
||||
|
||||
it('preserves independent art viewports across sort and workbench mode switches', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-art-viewport-memory',
|
||||
@@ -5800,22 +5823,35 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
/\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*position:\s*relative[^}]*display:\s*block[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*196px[^}]*scroll-padding-bottom:\s*196px/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{[^}]*position:\s*absolute[^}]*bottom:\s*0;[^}]*left:\s*0/s,
|
||||
/\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*12px[^}]*scroll-padding-bottom:\s*12px/s,
|
||||
);
|
||||
// direct-codex 的列表不再绝对定位在会话区上(那是"输入区浮在列表之上"那版几何):
|
||||
// 它在文档流里靠 `flex: 1 1 auto` 吸收剩余高度,是整块面板唯一的滚动区。最终生效几何
|
||||
// 由 tests/chatDialogFrameLayout.test.ts 按层叠求值验证,这里只钉住这两条声明在场。
|
||||
const directMessageListRule =
|
||||
styles.match(
|
||||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{([^}]*)\}/s,
|
||||
)?.[1] ?? '';
|
||||
expect(directMessageListRule).toContain('position: relative;');
|
||||
expect(directMessageListRule).toContain('flex: 1 1 auto;');
|
||||
expect(styles).toMatch(
|
||||
/\.game-workbench-chat \.agent-runtime-status\s*\{[^}]*max-height:\s*clamp\(120px, 24dvh, 240px\)[^}]*overflow-y:\s*auto/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-workbench-chat \.project-runtime-summary\s*\{[^}]*position:\s*sticky[^}]*top:\s*-10px/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 82px[^}]*z-index:\s*2[^}]*padding-top:\s*8px[^}]*background:\s*transparent/s,
|
||||
);
|
||||
// 输入盒是三行网格(信息标签 / 文本区 / 控制排)的文档流块,不再是贴在列表下边的
|
||||
// 绝对定位浮层,也不再与 82px 的发送钮列共用网格。
|
||||
const composerRule = styles.match(
|
||||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s*\{([^}]*)\}/s,
|
||||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\.is-direct-codex\s*\{([^}]*)\}/s,
|
||||
);
|
||||
expect(composerRule?.[1]).not.toBeUndefined();
|
||||
expect(composerRule?.[1]).toContain('position: relative;');
|
||||
expect(composerRule?.[1]).toContain('grid-template-rows: auto auto auto;');
|
||||
expect(composerRule?.[1]).toContain('z-index: 1;');
|
||||
expect(composerRule?.[1]).toContain('padding: 8px 12px 10px;');
|
||||
expect(composerRule?.[1]).toContain(
|
||||
'background: var(--platform-input-fill);',
|
||||
);
|
||||
expect(composerRule?.[1]).not.toContain('border-top:');
|
||||
expect(styles).toMatch(
|
||||
@@ -5832,8 +5868,10 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
);
|
||||
// 提交按钮这条只发给 `.project-supervisor-submit-button`:以前是 composer 下所有
|
||||
// `button`,会把绝对定位广播到输入区里的「AI 润色」上,把它浮到文本区中间。
|
||||
// 旧版这里还钉着 `min-height: 72px` 的整行提交条;Codex 版的提交钮是控制排里的
|
||||
// 28px 方钮,尺寸规则与 `+` / `@` 两只方钮同组,这里改成钉那组规则。
|
||||
expect(styles).toMatch(
|
||||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+\.project-supervisor-submit-button\s*\{[^}]*min-height:\s*72px[^}]*white-space:\s*nowrap/s,
|
||||
/\.project-supervisor-composer-controls\s+\.project-supervisor-attachment-trigger,[\s\S]*?\.project-supervisor-composer-controls\s+\.project-supervisor-submit-button\s*\{[^}]*width:\s*28px[^}]*flex:\s*0 0 28px/s,
|
||||
);
|
||||
expect(styles).not.toMatch(
|
||||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+button\s*\{/s,
|
||||
@@ -5867,7 +5905,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
/@media \(max-width: 760px\)[\s\S]*?\.game-workbench-layout\s*\{[^}]*height:\s*auto/,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/@media \(max-width: 760px\)[\s\S]*?\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{[^}]*position:\s*static[^}]*height:\s*52vh[^}]*flex:\s*1 1 auto/s,
|
||||
/@media \(max-width: 760px\)[\s\S]*?\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{[^}]*padding-bottom:\s*0[^}]*scroll-padding-bottom:\s*0[^}]*background:\s*transparent/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-resource-canvas-content\s*\{[^}]*width:\s*100%[^}]*min-width:\s*max\(100%, 620px\)/s,
|
||||
@@ -5902,9 +5940,15 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/<UiEditorPage[\s\S]*?walletEntry=\{walletEntry\}[\s\S]*?\/>/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/<div className="game-workbench-chat-wallet">\{walletEntry\}<\/div>/,
|
||||
// 会话列头部按 Codex 风格精简掉了标题/审批按钮/钱包槽。`walletEntry` 的落点只剩两处:
|
||||
// UI 编辑器头部(上面那条断言),以及做方案分支头部(`planningStartMode`,本任务明确
|
||||
// 保持原样)。右侧对话面板自己的「泥点」行改由 `ProjectSupervisorView` 的设置浮层承载,
|
||||
// 不再在 `game-workbench-chat` 头部渲染 `<div className="game-workbench-chat-wallet">`。
|
||||
const chatWalletSlots = projectDevelopmentSource.match(
|
||||
/<div className="game-workbench-chat-wallet">\{walletEntry\}<\/div>/g,
|
||||
);
|
||||
expect(chatWalletSlots).toHaveLength(1);
|
||||
expect(projectDevelopmentSource).toMatch(/walletEntry=\{walletEntry\}/);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s,
|
||||
);
|
||||
@@ -6077,36 +6121,25 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\.is-direct-codex\s*\{([^}]*)\}/s,
|
||||
)?.[1] ?? '';
|
||||
expect(composerRule).not.toBe('');
|
||||
// 输入区四边内缩:左右下三边各 12px 显式钉住,上边由 `max-height: calc(100% - 24px)`
|
||||
// 兜底,四条边都不与外框描边重合,底边还留出 12px 边距。
|
||||
const inset = styleNumber(composerRule, 'right');
|
||||
expect(inset).toBeGreaterThan(0);
|
||||
expect(styleNumber(composerRule, 'left')).toBe(inset);
|
||||
expect(styleNumber(composerRule, 'bottom')).toBe(inset);
|
||||
expect(composerRule).toContain('position: absolute;');
|
||||
// 输入区自己那只盒子还在(拆掉边框/底色会让输入区变成没有边界的裸文本)。
|
||||
// 输入盒在文档流里(Codex 三段式的第三段):不再用 right/bottom/left 内缩钉在
|
||||
// 消息列表之上,四边一律归 0;它是整块面板里唯一有边框的容器。
|
||||
expect(styleNumber(composerRule, 'right')).toBe(0);
|
||||
expect(styleNumber(composerRule, 'left')).toBe(0);
|
||||
expect(styleNumber(composerRule, 'bottom')).toBe(0);
|
||||
expect(composerRule).toContain('position: relative;');
|
||||
expect(composerRule).toContain(
|
||||
'border: 1px solid var(--platform-surface-border);',
|
||||
);
|
||||
expect(composerRule).toContain('background: var(--platform-input-fill);');
|
||||
|
||||
// 消息列表给输入区留出位置:底边留白 = 内缩 + 输入区最高高度 + 间距。
|
||||
// 输入区最高高度 = 编辑器 max-height(140) + 输入框行距(8) + 操作排 28px 方钮(28)
|
||||
// + 输入框下内边距(4) + 输入区自己上下内边距(8 × 2) + 操作条(2 + 30 方钮) = 228;
|
||||
// 256 = 12px 内缩 + 228 + 16px 间距。这里按层叠"第一条"取值,改动后务必同时跑
|
||||
// tests/chatDialogFrameLayout.test.ts——那条用例按层叠生效值验同一组几何(含窄屏),
|
||||
// 后面再写一条同选择器的规则顶掉这里,只有那条会失败。
|
||||
const composerClearance = styleNumber(
|
||||
messageListRule,
|
||||
'scroll-padding-bottom',
|
||||
);
|
||||
expect(composerClearance).toBeGreaterThanOrEqual(inset + 228);
|
||||
expect(composerClearance).toBe(inset + 228 + 16);
|
||||
const messageListPadding =
|
||||
/padding:\s*18px 20px (\d+)px;/u.exec(messageListRule)?.[1] ?? '';
|
||||
expect(Number(messageListPadding)).toBe(composerClearance);
|
||||
// 输入盒在文档流里,消息列表不再需要给它留位置:底边归 0 留白,滚动到底
|
||||
// 不会多出一段空白。具体几何(含窄屏)由 tests/chatDialogFrameLayout.test.ts 按
|
||||
// 层叠生效值验证;这里钉住"旧模型的数字没有被写回来"。
|
||||
expect(styleNumber(messageListRule, 'scroll-padding-bottom')).toBe(0);
|
||||
expect(messageListRule).toContain('padding-bottom: 0;');
|
||||
expect(messageListRule).toContain('flex: 1 1 auto;');
|
||||
|
||||
// 编辑器高度上限就是上面那个 228 的来源之一;改这里就必须同步改留白。
|
||||
// 编辑器高度上限仍是输入盒高度的来源之一。
|
||||
const editorRule =
|
||||
styles.match(
|
||||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+\.resource-reference-input-editor\s*\{([^}]*)\}/s,
|
||||
@@ -6150,7 +6183,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(userMessageRule).toContain('border-radius: 14px 14px 4px;');
|
||||
expect(userMessageRule).toContain('background: var(--platform-warm-bg);');
|
||||
expect(userMessageRule).toContain('color: var(--platform-text-base);');
|
||||
expect(processCardRules).toHaveLength(1);
|
||||
// 执行过程卡有两条同选择器规则:第一条是几何(`width: 100%`),后面那条是 Codex 暖色
|
||||
// 皮肤下的配色(把基础规则的绿系换成中性描边 + 暖底)。承重的是几何那条。
|
||||
expect(processCardRules.length).toBeGreaterThanOrEqual(1);
|
||||
expect(processCardRules[0]).toContain('width: 100%;');
|
||||
});
|
||||
|
||||
@@ -6674,16 +6709,16 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
expect(anchor).toMatch(/flex:\s*0 1 auto/u);
|
||||
expect(anchor).toMatch(/min-width:\s*0/u);
|
||||
|
||||
// 输入区高度变化不得推动同排操作元素:composer 是底边锚定的两行网格,输入区占第一
|
||||
// 行、控制排占第二行;输入区从 96px 长到 140px 只把 composer 顶边抬高,控制排仍钉在
|
||||
// composer 底边(`bottom: 12px`),发送钮与模型选择钮不动。
|
||||
// 输入盒高度变化不得推动同排操作元素:composer 是三行网格(信息标签 / 文本区 /
|
||||
// 控制排),文本区从 96px 长到 140px 只把 composer 顶边抬高,控制排仍在同一行的
|
||||
// 下面一行(发送钮与模型选择钮不动)。它不再靠 `position: absolute` 底边锚定。
|
||||
const composer = styleRuleBody(
|
||||
styles,
|
||||
'\\.game-workbench-chat\\s+\\.project-supervisor-surface\\.is-direct-codex\\s+\\.project-supervisor-composer\\.is-direct-codex',
|
||||
);
|
||||
expect(composer).toMatch(/position:\s*absolute/u);
|
||||
expect(styleNumber(composer, 'bottom')).toBeGreaterThan(0);
|
||||
expect(composer).toMatch(/grid-template-rows:\s*auto auto/u);
|
||||
expect(composer).toMatch(/position:\s*relative/u);
|
||||
expect(composer).toMatch(/grid-template-rows:\s*auto auto auto/u);
|
||||
expect(styleNumber(composer, 'bottom')).toBe(0);
|
||||
// 控制排是单行 flex、不换行:窄屏下靠可压缩的模型选择钮(flex 0 1 auto + min-width 0)
|
||||
// 收窄,而不是把模型选择钮/发送钮挤到第二行。
|
||||
const controls = styleRuleBody(
|
||||
@@ -6692,13 +6727,13 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
);
|
||||
expect(controls).toMatch(/display:\s*flex/u);
|
||||
expect(controls).not.toMatch(/flex-wrap/u);
|
||||
// `@` 引用钮与发送钮共用一条尺寸规则(同一规则体里两个选择器),都必须不可压缩,
|
||||
// 这样窄屏下被压的是可收缩的模型选择钮,而不是把这两个方钮挤出这一行。
|
||||
// `+` 附件钮 / `@` 引用钮 / 发送钮共用一条尺寸规则(同一规则体里三个选择器),都必须
|
||||
// 不可压缩,这样窄屏下被压的是可收缩的模型选择钮,而不是把方钮挤出这一行。
|
||||
const controlsButtons = styleRuleBody(
|
||||
styles,
|
||||
'\\.project-supervisor-composer-controls\\s+\\n?\\s*\\.project-supervisor-submit-button',
|
||||
);
|
||||
expect(controlsButtons).toMatch(/flex:\s*0 0 30px/u);
|
||||
expect(controlsButtons).toMatch(/flex:\s*0 0 28px/u);
|
||||
});
|
||||
|
||||
it('bounds the model dropdown height so a long catalog cannot cover the composer', () => {
|
||||
@@ -7996,6 +8031,150 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
).toHaveLength(policyReadCountBeforeChat + 1);
|
||||
});
|
||||
|
||||
it('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => {
|
||||
// 空态只在 direct-codex 面板、且没有任何消息/流式回复/待确认时出现。真实项目打开时
|
||||
// App 总会先放一条默认问候(`createDefaultChatMessages`),所以这里直接挂面板本体,
|
||||
// 把「空消息」这一格单独钉住。
|
||||
const projectPath = '/tmp/launcher-codex-panel-game';
|
||||
const view = render(
|
||||
React.createElement(ProjectSupervisorView, {
|
||||
activeVersionId: null,
|
||||
chatInput: '',
|
||||
chatReferences: [],
|
||||
chatProjectAssets: [],
|
||||
composerRef: createRef(),
|
||||
directCodex: true,
|
||||
directStatus: null,
|
||||
directProcessDetail: '',
|
||||
directProcessKey: '',
|
||||
hiddenConversationCount: 0,
|
||||
messagesRef: createRef(),
|
||||
needsUserInput: false,
|
||||
onCancelConfirmation: vi.fn(),
|
||||
onCancelPendingCommand: vi.fn(),
|
||||
onChatInputChange: vi.fn(),
|
||||
onConfirmConfirmation: vi.fn(),
|
||||
onConfirmPendingCommand: vi.fn(),
|
||||
onScroll: vi.fn(),
|
||||
onShowEarlierMessages: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
pendingConfirmation: null,
|
||||
pendingCommand: null,
|
||||
projectPath,
|
||||
transientReply: '',
|
||||
visibleMessages: [],
|
||||
visibleProfessionalAgentCards: [],
|
||||
workspaceStatus: '等待指令',
|
||||
planGddState: createPlanGddStateView(),
|
||||
planGddHydrateBusy: false,
|
||||
planGddDecisionBusy: false,
|
||||
planGddError: null,
|
||||
onPlanGddRefresh: vi.fn(),
|
||||
onPlanGddDecision: vi.fn(),
|
||||
runtime: null,
|
||||
error: '',
|
||||
runtimeByAgentId: {},
|
||||
controlBusy: false,
|
||||
professionalResultsByAgentId: {},
|
||||
onToolAction: vi.fn(),
|
||||
onSupervisorRetry: vi.fn(),
|
||||
onProfessionalToolAction: vi.fn(),
|
||||
onProfessionalRetry: vi.fn(),
|
||||
onUserInput: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const supervisorSurface = screen.getByLabelText('陶泥儿项目对话');
|
||||
// 空态:主标题 + 一句短指令副标题(面板里不写功能说明),整体落在消息区里居中。
|
||||
const emptyState = supervisorSurface.querySelector(
|
||||
'.project-supervisor-empty-state',
|
||||
);
|
||||
expect(emptyState).not.toBeNull();
|
||||
expect(
|
||||
within(emptyState as HTMLElement).getByText('你想让陶泥儿做什么游戏?'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(emptyState as HTMLElement).getByText(
|
||||
'告诉陶泥儿接下来要做什么,或输入 @ 选择资源',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
supervisorSurface
|
||||
.querySelector('.project-supervisor-message-list')
|
||||
?.contains(emptyState),
|
||||
).toBe(true);
|
||||
// 顶栏:状态文案带 aria-live,旧的头部标题与审批按钮都不在。
|
||||
expect(
|
||||
within(supervisorSurface).getByText('等待指令', {
|
||||
selector: '[aria-live="polite"]',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
expect(within(supervisorSurface).queryByText('与陶泥儿的对话')).toBeNull();
|
||||
expect(
|
||||
within(supervisorSurface).queryByRole('button', { name: /审批配置/ }),
|
||||
).toBeNull();
|
||||
// 输入盒三段式:信息标签行 / 文本区 / 控制排。项目名 + 本地两个标签,`+` 与 `@` 都在。
|
||||
const composer = supervisorSurface.querySelector(
|
||||
'form.project-supervisor-composer',
|
||||
);
|
||||
expect(composer).not.toBeNull();
|
||||
const context = composer?.querySelector(
|
||||
'.project-supervisor-composer-context',
|
||||
);
|
||||
expect(
|
||||
within(context as HTMLElement).getByText('launcher-codex-panel-game'),
|
||||
).not.toBeNull();
|
||||
expect(within(context as HTMLElement).getByText('本地')).not.toBeNull();
|
||||
expect(
|
||||
within(composer as HTMLElement).getByRole('button', {
|
||||
name: '添加素材引用',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(composer as HTMLElement).getByRole('button', {
|
||||
name: '插入素材引用',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(composer as HTMLElement).getByLabelText('陶泥儿对话内容'),
|
||||
).not.toBeNull();
|
||||
|
||||
// 设置浮层:独立浮层,包含运行配置与操作权限。
|
||||
fireEvent.click(
|
||||
within(supervisorSurface).getByRole('button', { name: '设置' }),
|
||||
);
|
||||
const settingsDialog = await screen.findByRole('dialog', {
|
||||
name: '对话设置',
|
||||
});
|
||||
expect(settingsDialog).not.toBeNull();
|
||||
expect(within(settingsDialog).getByText('运行配置')).not.toBeNull();
|
||||
// 打开审批模式:它是浮层里的二级浮层,选项在,选完还能退回设置浮层。
|
||||
fireEvent.click(
|
||||
within(settingsDialog).getByRole('button', {
|
||||
name: '审批配置,当前严格审批',
|
||||
}),
|
||||
);
|
||||
const approvalDialog = await screen.findByRole('dialog', {
|
||||
name: '陶泥儿的操作权限',
|
||||
});
|
||||
expect(
|
||||
within(approvalDialog).getByRole('radio', { name: /严格审批/ }),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(approvalDialog).getByRole('button', { name: '完成' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '陶泥儿的操作权限' }),
|
||||
).toBeNull();
|
||||
});
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '对话设置' })).toBeNull();
|
||||
});
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it('does not poll or render legacy professional Agent runtime state in direct product chat', async () => {
|
||||
const projectPath = '/tmp/launcher-runtime-status-game';
|
||||
let professionalRuntimeReadCount = 0;
|
||||
|
||||
@@ -96,6 +96,27 @@ function paddingBox(declarations: Map<string, string>) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 展开 `margin` 简写,只关心四边数值。 */
|
||||
function marginBox(declarations: Map<string, string>) {
|
||||
const shorthand = declarations.get('margin') ?? '0';
|
||||
const parts = shorthand.split(' ').filter(Boolean);
|
||||
const value = (index: number) => {
|
||||
const part = parts[index] ?? parts[parts.length - 1] ?? parts[0];
|
||||
return lengthPx(part!, `margin 简写 ${shorthand}`);
|
||||
};
|
||||
if (parts.length === 1) {
|
||||
const only = value(0);
|
||||
return { top: only, right: only, bottom: only, left: only };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { top: value(0), right: value(1), bottom: value(0), left: value(1) };
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return { top: value(0), right: value(1), bottom: value(2), left: value(1) };
|
||||
}
|
||||
return { top: value(0), right: value(1), bottom: value(2), left: value(3) };
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
选择器身份
|
||||
============================================================ */
|
||||
@@ -103,6 +124,8 @@ function paddingBox(declarations: Map<string, string>) {
|
||||
const CHAT = '.game-workbench-chat .project-supervisor-surface.is-direct-codex';
|
||||
const CONVERSATION = `${CHAT} .project-supervisor-conversation`;
|
||||
const MESSAGE_LIST = `${CHAT} .project-supervisor-message-list`;
|
||||
const MESSAGE_LIST_PLAIN =
|
||||
'.game-workbench-chat .project-supervisor-message-list';
|
||||
const COMPOSER = `${CHAT} .project-supervisor-composer.is-direct-codex`;
|
||||
const COMPOSER_PLAIN = `${CHAT} .project-supervisor-composer`;
|
||||
const CONVERSATION_COMPOSER = `${CONVERSATION} .project-supervisor-composer.is-direct-codex`;
|
||||
@@ -111,10 +134,18 @@ const COMPOSER_INPUT_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input`;
|
||||
const COMPOSER_EDITOR = `${COMPOSER_PLAIN} .resource-reference-input-editor`;
|
||||
const COMPOSER_CONTROLS = `${COMPOSER} .project-supervisor-composer-controls`;
|
||||
const COMPOSER_SUBMIT = `${COMPOSER_CONTROLS} .project-supervisor-submit-button`;
|
||||
const COMPOSER_CONTEXT = `${COMPOSER_PLAIN} .project-supervisor-composer-context`;
|
||||
const INPUT_ACTIONS = `${COMPOSER} .resource-reference-input-actions`;
|
||||
const TOPBAR = `${CHAT} .project-supervisor-topbar`;
|
||||
// 策划链在会话列里额外挂了一条规划面/窄条时,列表会命中这条 `:has(...)` 规则——
|
||||
// 它同样声明了 `padding-bottom`,正是上一轮把留白改回 12px 的那种隐患。
|
||||
// 它同样声明了 `padding-bottom`,是这套几何里唯一"外来的"留白来源。
|
||||
const LIST_WITH_PLAN_SURFACE = `.game-workbench-chat .project-supervisor-conversation:has(.plan-gdd-surface, .planning-lane-runtime-strip) .project-supervisor-message-list`;
|
||||
/** 列表实际会命中的全部选择器:direct-codex 直连、`:has(...)` 变体、以及基础规则。 */
|
||||
const LIST_SELECTORS = [
|
||||
MESSAGE_LIST,
|
||||
LIST_WITH_PLAN_SURFACE,
|
||||
MESSAGE_LIST_PLAIN,
|
||||
];
|
||||
|
||||
const DESKTOP = 1440;
|
||||
const MOBILE = 390;
|
||||
@@ -130,7 +161,12 @@ function mobileDeclarations(...elementSelectors: string[]) {
|
||||
return resolveDeclarations(rules, elementSelectors, MOBILE);
|
||||
}
|
||||
|
||||
/** 留白算术里的每一个常量都必须来自文件里真实生效的声明。 */
|
||||
/**
|
||||
* 输入盒在文档流里的最高高度。
|
||||
*
|
||||
* 新结构里输入盒**不再**需要消息列表给它让位,这个值只用来给「列表底边不留白」做
|
||||
* 下界校验:留白只要不小于它就说明旧模型的数字被写回来了。
|
||||
*/
|
||||
function composerMaxHeight() {
|
||||
const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
|
||||
const input = desktopDeclarations(
|
||||
@@ -150,6 +186,7 @@ function composerMaxHeight() {
|
||||
`${INPUT_ACTIONS} .resource-reference-input-polish`,
|
||||
'.resource-reference-input-polish',
|
||||
);
|
||||
const context = desktopDeclarations(COMPOSER_CONTEXT);
|
||||
const controls = desktopDeclarations(COMPOSER_CONTROLS);
|
||||
const submit = desktopDeclarations(COMPOSER_SUBMIT);
|
||||
|
||||
@@ -164,12 +201,15 @@ function composerMaxHeight() {
|
||||
|
||||
expect(
|
||||
pixelValue(editor, 'min-height'),
|
||||
'编辑器最小高度是留白算式的下界,改了要同步改留白',
|
||||
'编辑器最小高度是输入盒高度的下界,改了要同步改这里的算式',
|
||||
).toBe(96);
|
||||
// 操作排的高度就是那三只 28px 方钮自己撑起来的:它自己不设 height / min-height,
|
||||
// 一旦设了,网格行高会被顶起来、编辑器的 min-height 也跟着变,算式随之作废。
|
||||
expect(actions.has('height')).toBe(false);
|
||||
expect(actions.has('min-height')).toBe(false);
|
||||
// 信息标签行只在 direct-codex 渲染,也只占一行,不设固定高度。
|
||||
expect(context.has('height')).toBe(false);
|
||||
expect(context.has('min-height')).toBe(false);
|
||||
|
||||
return {
|
||||
actionsRowHeight,
|
||||
@@ -184,166 +224,139 @@ function composerMaxHeight() {
|
||||
};
|
||||
}
|
||||
|
||||
describe('陶泥儿对话区:外框完整包住输入区', () => {
|
||||
it('外框四边贴会话区,输入区四边都在外框之内且不重合(宽屏)', () => {
|
||||
const list = desktopDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE);
|
||||
describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文档流输入盒)', () => {
|
||||
it('输入盒在消息列表下方、处于文档流,不再浮在列表之上(宽屏)', () => {
|
||||
const list = desktopDeclarations(...LIST_SELECTORS);
|
||||
const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
|
||||
|
||||
// 先看不重合关系再钉绝对位移:底边必须"外框在外、输入区在内",留白 > 0。
|
||||
// (上一轮的残留 bug 就是外框底边被另一条 `bottom: 156px` 顶到输入区腰上。)
|
||||
const frameBottom = pixelValue(list, 'bottom');
|
||||
const frameTop = pixelValue(list, 'top');
|
||||
const frameLeft = pixelValue(list, 'left');
|
||||
const frameRight = pixelValue(list, 'right');
|
||||
const composerBottom = pixelValue(composer, 'bottom');
|
||||
const composerLeft = pixelValue(composer, 'left');
|
||||
const composerRight = pixelValue(composer, 'right');
|
||||
// 列表是唯一滚动区:自身不再绝对定位,靠 flex 吸收会话列的剩余高度。
|
||||
expect(declaration(list, 'position')).toBe('relative');
|
||||
expect(declaration(list, 'overflow-y')).toBe('auto');
|
||||
expect(declaration(list, 'flex')).toContain('1 1 auto');
|
||||
|
||||
const inset = composerBottom;
|
||||
expect(inset, '输入区必须与外框底边留出可见边距').toBeGreaterThan(0);
|
||||
expect(
|
||||
composerBottom - frameBottom,
|
||||
'外框底边必须严格低于输入区底边',
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
composerLeft - frameLeft,
|
||||
'外框左边必须在输入区左边之外',
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
composerRight - frameRight,
|
||||
'外框右边必须在输入区右边之外',
|
||||
).toBeGreaterThan(0);
|
||||
expect(pixelValue(composer, 'right')).toBe(inset);
|
||||
expect(composerLeft - frameLeft).toBe(inset);
|
||||
expect(composerRight - frameRight).toBe(inset);
|
||||
// 输入盒在文档流里:不绝对定位;它紧贴列表下边(面板很窄,输入盒自己已有内边距,
|
||||
// 再叠外边距会浪费高度),关键是它不再落在列表**内部**覆盖消息。
|
||||
expect(declaration(composer, 'position')).toBe('relative');
|
||||
const insets = marginBox(composer);
|
||||
expect(insets.top).toBe(0);
|
||||
expect(insets.left).toBe(0);
|
||||
expect(insets.right).toBe(0);
|
||||
expect(insets.bottom).toBe(0);
|
||||
// 也不再靠 bottom/left/right 偏移量定位(那是旧浮层几何的残余)。
|
||||
expect(pixelValue(composer, 'bottom')).toBe(0);
|
||||
expect(pixelValue(composer, 'left')).toBe(0);
|
||||
expect(pixelValue(composer, 'right')).toBe(0);
|
||||
|
||||
// 外框四边就是会话区那只盒子——它才是"对话框"。
|
||||
expect(frameTop).toBe(0);
|
||||
expect(frameRight).toBe(0);
|
||||
expect(frameBottom).toBe(0);
|
||||
expect(frameLeft).toBe(0);
|
||||
|
||||
// 上边不钉死(底边锚定、向上生长),由 max-height 兜住:任何情况下上边至少离外框 12px。
|
||||
expect(declaration(composer, 'position')).toBe('absolute');
|
||||
expect(composer.has('top')).toBe(false);
|
||||
expect(declaration(composer, 'max-height')).toBe(
|
||||
`calc(100% - ${inset * 2}px)`,
|
||||
);
|
||||
|
||||
// 输入区自己那只盒子还在(拆掉边框/底色会让输入区变成没有边界的裸文本)。
|
||||
// 输入盒自己那只盒子还在(它现在是整块面板里唯一有边框的容器)。
|
||||
expect(declaration(composer, 'border')).toContain('1px solid');
|
||||
expect(declaration(composer, 'background')).toContain(
|
||||
'var(--platform-input-fill)',
|
||||
);
|
||||
expect(declaration(composer, 'border-radius')).toBe('14px');
|
||||
|
||||
// 对话内容左右留白 16px,消息之间 14px。
|
||||
expect(paddingBox(list).left).toBe(16);
|
||||
expect(paddingBox(list).right).toBe(16);
|
||||
expect(declaration(list, 'gap')).toBe('14px');
|
||||
});
|
||||
|
||||
it('消息列表底部留白 = 输入区内缩 + 输入区最高高度 + 间距,且与 scroll-padding 同值', () => {
|
||||
const list = desktopDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE);
|
||||
const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
|
||||
it('消息列表底部不留给浮层的空白,滚动到底不会多出一段空白(宽屏)', () => {
|
||||
const list = desktopDeclarations(...LIST_SELECTORS);
|
||||
const { total, actionsRowHeight } = composerMaxHeight();
|
||||
|
||||
const inset = pixelValue(composer, 'bottom');
|
||||
const gap = 16;
|
||||
const paddingBottom = pixelValue(list, 'padding-bottom');
|
||||
|
||||
// 输入区最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4
|
||||
// + 输入区上下内边距 16 + 操作条(2 + 30 方钮)= 228。
|
||||
expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算术式').toBe(28);
|
||||
// 输入盒最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4
|
||||
// + 输入盒上下内边距 18 + 操作条(2 + 28 方钮)= 228。
|
||||
expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算式').toBe(28);
|
||||
expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28);
|
||||
expect(total).toBe(228);
|
||||
|
||||
// 留白 ≥ 输入区最高高度 + 内缩:滚到底时最后一条消息不会被输入区盖住。
|
||||
expect(paddingBottom).toBeGreaterThanOrEqual(inset + total);
|
||||
// 且留白就该等于这条算术式,不允许多出一个"来历不明"的常量。
|
||||
expect(paddingBottom).toBe(inset + total + gap);
|
||||
const paddingBottom = paddingBox(list).bottom;
|
||||
// 新契约:留白归 0。旧版这里是一条 `inset + 输入区最高高度 + 间距` 的算式,
|
||||
// 输入盒回到文档流之后,那段留白会变成凭空多出来的空白,把最后一条消息顶出可视区。
|
||||
expect(paddingBottom, '输入盒在文档流里,列表底边不允许再给浮层留白').toBe(
|
||||
0,
|
||||
);
|
||||
expect(pixelValue(list, 'scroll-padding-bottom')).toBe(paddingBottom);
|
||||
// 留白必须严格小于输入盒高度,否则就是把旧模型的数字又写回来了。
|
||||
expect(paddingBottom).toBeLessThan(total);
|
||||
});
|
||||
|
||||
it('窄屏把外框交给会话列,输入区回到文档流,四周仍有一圈边距(390px)', () => {
|
||||
it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => {
|
||||
const conversation = mobileDeclarations(CONVERSATION);
|
||||
const list = mobileDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE);
|
||||
const list = mobileDeclarations(...LIST_SELECTORS);
|
||||
const composer = mobileDeclarations(
|
||||
COMPOSER,
|
||||
COMPOSER_PLAIN,
|
||||
CONVERSATION_COMPOSER,
|
||||
);
|
||||
const topbar = mobileDeclarations(TOPBAR);
|
||||
|
||||
// 外框画在列表与输入区共同的父节点上,用的还是那套 token。
|
||||
// 会话列不再自画一只外框:那块框交给输入盒,避免"框里再套一只框"。
|
||||
const framePadding = paddingBox(conversation);
|
||||
const inset = framePadding.top;
|
||||
expect(inset, '窄屏外框必须给输入区留出可见边距').toBeGreaterThan(0);
|
||||
expect(framePadding.right).toBe(inset);
|
||||
expect(framePadding.bottom).toBe(inset);
|
||||
expect(framePadding.left).toBe(inset);
|
||||
expect(declaration(conversation, 'border')).toContain(
|
||||
'var(--platform-subpanel-border)',
|
||||
);
|
||||
expect(declaration(conversation, 'background')).toContain(
|
||||
'var(--platform-input-fill)',
|
||||
);
|
||||
expect(declaration(conversation, 'border-radius')).toBe('12px');
|
||||
expect(framePadding.top).toBe(0);
|
||||
expect(framePadding.right).toBe(0);
|
||||
expect(framePadding.bottom).toBe(0);
|
||||
expect(framePadding.left).toBe(0);
|
||||
expect(declaration(conversation, 'border')).toBe('0');
|
||||
expect(declaration(conversation, 'background')).toBe('transparent');
|
||||
|
||||
// 列表在窄屏不再自画一只框:框里不套框,也就不会出现两条描边压在一起。
|
||||
const listFrame = list.get('border');
|
||||
expect(listFrame).toBeDefined();
|
||||
expect(listFrame).not.toContain('1px');
|
||||
expect(declaration(list, 'background')).toBe('transparent');
|
||||
expect(declaration(list, 'position')).toBe('static');
|
||||
|
||||
// 输入区回到文档流:它是外框的子节点,四边由外框内边距让出,不会再盖住消息。
|
||||
// 输入盒仍在文档流(本来就在),四周由自己的外边距让出一圈留白。
|
||||
expect(declaration(composer, 'position')).toBe('relative');
|
||||
expect(declaration(composer, 'bottom')).toBe('auto');
|
||||
expect(declaration(composer, 'left')).toBe('auto');
|
||||
expect(declaration(composer, 'right')).toBe('auto');
|
||||
expect(declaration(composer, 'max-height')).toBe('none');
|
||||
// 流内元素只与列表上下相邻,列表底边留 12px 与它分开。
|
||||
expect(pixelValue(list, 'padding-bottom')).toBe(inset);
|
||||
const composerMargin = marginBox(composer);
|
||||
expect(composerMargin.left, '窄屏输入盒左右必须有边距').toBeGreaterThan(0);
|
||||
expect(composerMargin.right).toBe(composerMargin.left);
|
||||
expect(composerMargin.bottom, '窄屏输入盒底部必须有边距').toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
|
||||
// 列表在窄屏也不画框、不留浮层空白,它仍是唯一滚动区。
|
||||
expect(declaration(list, 'background')).toBe('transparent');
|
||||
expect(pixelValue(list, 'padding-bottom')).toBe(0);
|
||||
expect(declaration(list, 'overflow-y')).toBe('auto');
|
||||
|
||||
// 顶栏在窄屏也在场(状态点 + 设置钮),高度不塌。
|
||||
expect(declaration(topbar, 'display')).toBe('flex');
|
||||
expect(pixelValue(topbar, 'min-height')).toBe(44);
|
||||
});
|
||||
|
||||
it('输入区仍落在对话框外框节点的子树里,交互控件原位', () => {
|
||||
it('输入盒仍落在 form.project-supervisor-composer 子树里,交互控件原位', () => {
|
||||
const source = readFileSync(VIEW_PATH, 'utf8');
|
||||
const formOpen = source.indexOf('<form');
|
||||
expect(formOpen, '输入盒(form)缺失').toBeGreaterThanOrEqual(0);
|
||||
const formEnd = source.indexOf('</form>');
|
||||
expect(formEnd, '输入盒(form)没有闭合').toBeGreaterThan(formOpen);
|
||||
const formSource = source.slice(formOpen, formEnd + '</form>'.length);
|
||||
|
||||
// 编辑器、`+` / `@` 引用、模型选择、提交按钮全在这棵子树里,位置只由样式挪。
|
||||
expect(formSource).toContain('ResourceReferenceInput');
|
||||
expect(formSource).toContain('project-supervisor-composer-context');
|
||||
expect(formSource).toContain('project-supervisor-composer-controls');
|
||||
expect(formSource).toContain('project-supervisor-attachment-trigger');
|
||||
expect(formSource).toContain('project-supervisor-reference-trigger');
|
||||
expect(formSource).toContain('ConversationModelSelect');
|
||||
expect(formSource).toContain('{submitButton}');
|
||||
expect(formSource).toContain('onSubmit={');
|
||||
// 提交按钮本体还是那只按钮,只是以变量形式引用进这棵子树。
|
||||
expect(source).toContain('className="project-supervisor-submit-button"');
|
||||
|
||||
// 面板三段式的顺序:顶栏 → 消息列表 → 输入盒。
|
||||
const conversationOpen = source.indexOf(
|
||||
'<div className="project-supervisor-conversation">',
|
||||
);
|
||||
expect(conversationOpen, '会话列容器缺失').toBeGreaterThanOrEqual(0);
|
||||
|
||||
// 用 div 配对找出会话列容器的闭合位置(属性里的箭头函数不影响 <div / </div> 计数)。
|
||||
const before = source.slice(conversationOpen);
|
||||
const divOpeners = (before.match(/<div(?=[\s>])/gu) ?? []).length;
|
||||
expect(divOpeners).toBeGreaterThan(0);
|
||||
let depth = 0;
|
||||
const cursor = conversationOpen;
|
||||
let conversationEnd = -1;
|
||||
const tokenPattern = /<div(?=[\s>])|<\/div>/gu;
|
||||
tokenPattern.lastIndex = conversationOpen;
|
||||
let token = tokenPattern.exec(source);
|
||||
while (token) {
|
||||
depth += token[0] === '</div>' ? -1 : 1;
|
||||
if (depth === 0) {
|
||||
conversationEnd = token.index + token[0].length;
|
||||
break;
|
||||
}
|
||||
token = tokenPattern.exec(source);
|
||||
}
|
||||
expect(conversationEnd, '会话列容器没有闭合').toBeGreaterThan(
|
||||
expect(conversationOpen).toBeGreaterThanOrEqual(0);
|
||||
const topbarIndex = source.indexOf(
|
||||
'project-supervisor-topbar',
|
||||
conversationOpen,
|
||||
);
|
||||
|
||||
const conversationSource = source.slice(conversationOpen, conversationEnd);
|
||||
expect(conversationSource).toContain('project-supervisor-message-list');
|
||||
expect(conversationSource).toContain('project-supervisor-composer');
|
||||
|
||||
const composerSource = conversationSource.slice(
|
||||
conversationSource.indexOf('<form'),
|
||||
conversationSource.indexOf('</form>') + '</form>'.length,
|
||||
const listIndex = source.indexOf(
|
||||
'className="message-list project-supervisor-message-list"',
|
||||
conversationOpen,
|
||||
);
|
||||
// 编辑器、@ 引用、AI 润色、模型选择、提交按钮全在这棵子树里,位置只由样式挪。
|
||||
expect(composerSource).toContain('ResourceReferenceInput');
|
||||
expect(composerSource).toContain('project-supervisor-composer-controls');
|
||||
expect(composerSource).toContain('project-supervisor-reference-trigger');
|
||||
expect(composerSource).toContain('ConversationModelSelect');
|
||||
expect(composerSource).toContain('{submitButton}');
|
||||
expect(composerSource).toContain('onSubmit={');
|
||||
// 提交按钮本体还是那只按钮,只是以变量形式引用进这棵子树。
|
||||
expect(source).toContain('className="project-supervisor-submit-button"');
|
||||
expect(topbarIndex).toBeGreaterThan(conversationOpen);
|
||||
expect(listIndex, '消息列表必须排在顶栏之后').toBeGreaterThan(topbarIndex);
|
||||
expect(formOpen, '输入盒必须排在消息列表之后').toBeGreaterThan(listIndex);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user