右侧对话面板改为 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:
2026-09-14 12:32:54 +08:00
parent 637bd90073
commit 92db1e7065
9 changed files with 1252 additions and 390 deletions
@@ -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>
@@ -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}
</>
);
}
@@ -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);
});
});