AGC 输入区三处观感修复:AI 润色归位、@ 面板筛选条单行、引用 chip 原子化
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Native shell tests (pull_request) Successful in 18m26s

- styles.css:把 direct-codex composer 下广播式的 `.project-supervisor-composer button` 规则
  收窄到 `.project-supervisor-submit-button`(含 svg 那条与 media query 里那条),
  并给 `.resource-reference-input-actions` 补 `grid-column: 1 / -1` 让输入区操作排独占一行
- styles.css:`.resource-reference-input-actions` / `.resource-reference-input-at` 撤掉绝对定位,
  操作排回到网格文档流,与输入框右下角对齐,不再浮在文本区中间
- styles.css:`.resource-reference-input-polish` 与 `.resource-reference-input-restore` 并入
  `@` 按钮同一条尺寸规则(28px 方钮、8px 圆角、同 hover/disabled 观感)
- styles.css:输入区状态行(润色中/润色失败)改为与应用排同一行,不再单独占一行顶高 22.8px
- styles.css:direct-codex 输入框 padding 收为 `12px 15px 6px`,窄屏 `10px 12px 6px`
- styles.css:`.resource-reference-chip` 的 `×` 删除按钮不再被 broadcast 规则摘出 chip
- PlatformSegmentedTabs.tsx:分段页签 item 加 `whitespace-nowrap`,中文不再逐字竖排
- packages/shared/styles.css:补 `.scrollbar-hide` 实现(该类名一直被 `layout="scroll"` 引用但仓库未定义)
- ResourceReferenceNode.tsx:`getTextContent()` 改为单个 U+FFFC 占位符并显式实现
  `getTextContentSize()`,引用成为真正的原子内联节点(1 个引用 = 1 个字符)
- tests/resourceReferenceInput.test.tsx:新增「1 个引用 = 1 个字符」与「chip 自带删除、一次删净」两条断言
- tests/appSurface/project-development.suite.ts:把 composer 按钮样式断言收窄到提交按钮,
  并新增一条反向断言,钉住「广播到 composer 下所有 button」的规则不再出现
This commit is contained in:
2026-09-10 19:05:57 +08:00
parent c20e28bed0
commit cb76aadb48
6 changed files with 195 additions and 40 deletions
@@ -21,6 +21,9 @@ export type SerializedResourceReferenceNode = Spread<
SerializedLexicalNode
>;
/** 引用节点在纯文本里的唯一占位字符:一个 chip 就当一个字符。 */
const OBJECT_REPLACEMENT = '\uFFFC';
export class ResourceReferenceNode extends DecoratorNode<ReactNode> {
__reference: ChatReference;
@@ -58,8 +61,23 @@ export class ResourceReferenceNode extends DecoratorNode<ReactNode> {
return false;
}
/**
* 引用是原子内联节点:纯文本表示只占 **1 个字符**U+FFFC OBJECT REPLACEMENT
* CHARACTERUnicode 里专门用来占位「一个不可拆分的对象」)。
*
* 这决定了两件事:
* - 字数统计与光标偏移都按 1 算,`getTextContentSize()` 不必再跟着显示名长度走;
* - 复制成纯文本时一个 chip 也只会留下一个占位符,而不是把 `@显示名` 混进正文。
*
* 完整引用数据不走这里:草稿文本由 `ResourceReferenceInput` 的 `collectDraftParts`
* 用 `@${reference.label}` 拼装,结构化引用直接取 `__reference`,两者都不依赖本方法。
*/
getTextContent() {
return `@${this.__reference.label}`;
return OBJECT_REPLACEMENT;
}
getTextContentSize() {
return OBJECT_REPLACEMENT.length;
}
isInline() {
+61 -36
View File
@@ -3006,7 +3006,11 @@ textarea {
line-height: 1.5;
}
.project-supervisor-composer button {
/* 只发给提交按钮:这里原来是 `.project-supervisor-composer button`,会把 `position`、
`width`、`border-radius` 广播到输入区里的每一个按钮上——`.project-supervisor-composer
.is-direct-codex .project-supervisor-composer button` 那条绝对定位规则就是这样把
「AI 润色」从输入区网格里摘出去、浮到文本区右侧的。 */
.project-supervisor-submit-button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -3020,7 +3024,7 @@ textarea {
color: #fff;
}
.project-supervisor-composer button:disabled {
.project-supervisor-submit-button:disabled {
border-color: #cfd6e1;
background: #e8ecf2;
color: #8b95a5;
@@ -3308,7 +3312,7 @@ textarea {
grid-template-columns: 1fr;
}
.project-supervisor-composer button {
.project-supervisor-submit-button {
width: 100%;
}
@@ -4473,40 +4477,24 @@ h2 {
color: #8a94a6;
}
/* 输入区操作排是文本区下面的独立一行(`.resource-reference-input` 是行式网格,
第一行放编辑器)。`@` / AI 润色 / 恢复原文三个 28px 方钮共用一套外观,网格既有
的 `align-items: end` 已经把它们钉在行底,和下面那一排主操作对齐。
注意不要再给这一排加 `min-height`:网格行高由编辑器撑开,行高一旦被顶起来,
编辑器就会跟着变高。 */
.resource-reference-input-actions {
display: flex;
align-items: end;
gap: 6px;
}
.resource-reference-input-at {
display: grid;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #cfd6df;
border-radius: 8px;
background: #f8fafc;
color: #475569;
place-items: center;
cursor: pointer;
}
.resource-reference-input-at:hover:not(:disabled) {
border-color: #94a3b8;
color: #0f172a;
}
.resource-reference-input-at:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.resource-reference-input-at,
.resource-reference-input-polish,
.resource-reference-input-restore {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 28px;
padding: 0;
border: 1px solid #cfd6df;
border-radius: 8px;
@@ -4516,20 +4504,24 @@ h2 {
cursor: pointer;
}
.resource-reference-input-at:hover:not(:disabled),
.resource-reference-input-polish:hover:not(:disabled),
.resource-reference-input-restore:hover:not(:disabled) {
border-color: #94a3b8;
color: #0f172a;
}
.resource-reference-input-at:disabled,
.resource-reference-input-polish:disabled,
.resource-reference-input-restore:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* 润色中 / 润色失败:整行独占,既不会压住上面的编辑器,也不会被操作排挤成两行。 */
.resource-reference-input-status {
grid-column: 1 / -1;
margin-top: 6px;
color: #b45309;
font-size: 12px;
line-height: 1.4;
@@ -8461,7 +8453,7 @@ iframe.preview-frame {
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
button {
.project-supervisor-submit-button {
width: auto;
height: auto;
min-height: 72px;
@@ -8560,10 +8552,13 @@ iframe.preview-frame {
pointer-events: auto;
}
/* 只发给提交按钮。这条原来是 `.project-supervisor-composer button` 的通用规则,
会把 `position: absolute; right: 10px; bottom: 10px` 广播给 composer 里的每一个
按钮——输入区里的「AI 润色」就是这样被摘出网格、浮到文本区右侧的。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
button {
.project-supervisor-submit-button {
position: absolute;
right: 10px;
bottom: 10px;
@@ -8584,7 +8579,7 @@ iframe.preview-frame {
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
button
.project-supervisor-submit-button
svg {
width: 14px;
height: 14px;
@@ -9074,6 +9069,15 @@ iframe.preview-frame {
flex: 0 0 auto;
}
/* 窄屏:输入框左右留白收紧,操作排(`@` / AI 润色 / 恢复原文)保持单行不换行,
编辑器靠 `minmax(0, 1fr)` 吸收宽度,不会挤出横向滚动条。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input {
padding: 10px 12px 6px;
}
.game-workbench-chat .project-supervisor-surface,
.game-workbench-chat .project-supervisor-conversation {
height: auto;
@@ -10005,8 +10009,7 @@ iframe.preview-frame {
.resource-reference-input {
display: grid;
width: 100%;
min-height: 88px;
padding: 12px 46px 10px 15px;
padding: 12px 15px 6px;
border-color: var(--platform-surface-border);
border-radius: 14px;
background: var(--platform-input-fill);
@@ -10035,16 +10038,38 @@ iframe.preview-frame {
line-height: 1.6;
}
/* 陶泥儿输入区的操作排(`@` / AI 润色 / 恢复原文)回到文档流:`grid-column: 1 / -1`
让它独占编辑器下面的一行,`align-items: end` + `justify-content: flex-end` 把它
贴到输入框右下角。之前这里用 `position: absolute; right: 48px; bottom: 10px`
把整排从网格里摘出来浮在输入框中间,AI 润色因此看起来压在文本区里、和下面那排
主操作(`.project-supervisor-composer-controls` 的 `@` / 快速 / 发送)脱节。
不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把
编辑器的 `min-height: 96px` 改掉。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-actions {
position: absolute;
right: 48px;
bottom: 10px;
grid-column: 1 / -1;
position: static;
display: flex;
align-items: center;
height: 30px;
align-items: end;
justify-content: flex-end;
min-width: 0;
height: auto;
}
/* 「润色中… / 润色失败」和应用排同一行:状态文字靠左占剩余宽度,操作排靠右。
单独占一行会把输入框整体顶高 22.8px,把操作排和下面那排主操作推开一个状态行的
距离;共行则状态出现/消失都不改变输入框高度。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-status {
grid-column: 1 / -1;
grid-row: 2;
align-self: center;
min-width: 0;
margin-top: 0;
}
.game-workbench-chat
@@ -4045,8 +4045,13 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).toMatch(
/\.game-workbench-chat \.project-runtime-pending-command\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 136px/s,
);
// 提交按钮这条只发给 `.project-supervisor-submit-button`:以前是 composer 下所有
// `button`,会把绝对定位广播到输入区里的「AI 润色」上,把它浮到文本区中间。
expect(styles).toMatch(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+button\s*\{[^}]*min-height:\s*72px[^}]*white-space:\s*nowrap/s,
/\.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,
);
expect(styles).not.toMatch(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+button\s*\{/s,
);
expect(styles).toMatch(
/\.game-run-surface\s*\{[^}]*grid-template-rows:\s*minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s,
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical';
import { StrictMode } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
@@ -18,6 +19,7 @@ import {
type ChatReference,
currentIterationVersionAssets,
dispatchResourceReferenceInsert,
resolveActiveIterationVersion,
RESOURCE_REFERENCE_FILTERS,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_SCOPES,
@@ -25,7 +27,6 @@ import {
resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery,
resolveActiveIterationVersion,
} from '../src/features/project-workspace/resourceReferences';
function asset(
@@ -67,6 +68,29 @@ async function settleComposer() {
});
}
/**
* 编辑器文本模型里的字符数。断言「一个引用 = 一个字符」用它而不是 DOM 文本:
* DOM 里 chip 仍要显示 `@显示名` 给用户看,两者本来就不该相等。
*/
function composerEditor(): {
getEditorState: () => { read: <T>(fn: () => T) => T };
} {
const element = screen.getByLabelText('聊天') as HTMLElement & {
__lexicalEditor?: {
getEditorState: () => { read: <T>(fn: () => T) => T };
};
};
const editor = element.__lexicalEditor;
if (!editor) throw new Error('lexical editor instance not found');
return editor;
}
function editorTextSize() {
return composerEditor()
.getEditorState()
.read(() => $getRoot().getTextContentSize());
}
const assets = [
asset('hero', 'character', 'image/png', 'assets/hero.png'),
asset('enemy', 'character', 'image/png', 'assets/enemy.png'),
@@ -304,6 +328,78 @@ describe('ResourceReferenceInput', () => {
});
});
test('counts one inserted reference as a single character in the editor text model', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
// 引用是原子节点:编辑器文本模型里只占 1 个字符,加上插入时补的那个分隔空格
// 一共 2 个。显示名 `hero` 有 4 个字符(`@hero` 是 5),如果引用按显示名算,
// 这里会是 5 或 6,所以这条断言正好把「1 个引用 = 1 个字符」钉住。
// 提交用的结构化引用另走 `references`,不受这个占位字符影响。
expect(editorTextSize()).toBe(2);
});
test('keeps the whole reference after one delete and drops its data in one step', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
// chip 仍把 `@显示名` 显示给用户看;删除按钮挂在 chip 自己身上
// `移除引用 hero` 是 chip 内部的按钮,不是脱离 chip 的独立按钮)。
const chip = document.querySelector('[data-resource-reference-id="hero"]');
expect(chip).not.toBeNull();
expect(chip?.textContent).toBe('@hero');
const deleteButton = screen.getByRole('button', { name: '移除引用 hero' });
expect(chip?.contains(deleteButton)).toBe(true);
// 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero');
expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe(
'hero',
);
// 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。
await user.click(deleteButton);
await settleComposer();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).toBeNull();
expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0);
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('');
});
test('exposes the current-version and all-canvas scopes as the only two tabs', () => {
expect(RESOURCE_REFERENCE_SCOPES).toEqual([
{ id: 'current-version', label: '当前版本素材' },
@@ -154,7 +154,7 @@ function resolveItemClassName<TId extends string>({
: itemClassName;
return [
'min-w-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)]',
'min-w-0 whitespace-nowrap transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)]',
PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS[size],
active
? PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].active
+11
View File
@@ -508,6 +508,17 @@
padding-right: 0.1rem;
}
/* 横向滚动条按需隐藏:分段页签的 `layout="scroll"` 与标签排都靠它保持外观干净,
滚动能力本身不隐藏(内容仍可横向滚动,只是不画滚动条)。 */
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.platform-category-chip {
display: inline-flex;
min-height: 2.25rem;