优化Agent对话用户消息气泡 #179

Merged
kdletters merged 3 commits from codex/agent-chat-user-bubbles into master 2026-08-23 19:06:06 +08:00
4 changed files with 247 additions and 1 deletions
+57
View File
@@ -1090,10 +1090,17 @@ textarea {
.launcher-agent-chat-messages .message {
max-width: min(720px, 88%);
margin: 0;
padding: 9px 12px;
border-radius: 14px;
line-height: 1.55;
}
.launcher-agent-chat-messages .message--user {
justify-self: end;
border: 1px solid #d8c4a8;
border-radius: 14px 14px 4px;
background: #fff7ed;
color: #8a4b08;
}
.launcher-agent-chat-waiting {
@@ -4419,6 +4426,27 @@ h2 {
border: 1px solid #dde3ee;
}
.agent-conversation-list .message {
width: fit-content;
max-width: min(88%, 720px);
margin: 0;
padding: 9px 12px;
border-radius: 14px;
line-height: 1.55;
}
.agent-conversation-list .message + .message {
margin-top: 10px;
}
.agent-conversation-list .message--user {
margin-left: auto;
border: 1px solid #d8c4a8;
border-radius: 14px 14px 4px;
background: #fff7ed;
color: #8a4b08;
}
.agent-memory-box {
display: grid;
gap: 6px;
@@ -6227,6 +6255,35 @@ iframe.preview-frame {
scrollbar-gutter: stable;
}
/* Keep the transcript readable while making the user's turns immediately
distinct from the assistant stream, like a conventional chat surface. */
.game-workbench-chat .project-supervisor-message-list .message {
width: fit-content;
max-width: min(88%, 720px);
margin: 0;
padding: 9px 12px;
border-radius: 14px;
line-height: 1.55;
}
.game-workbench-chat .project-supervisor-message-list .message + .message {
margin-top: 10px;
}
.game-workbench-chat .project-supervisor-message-list .message--user {
margin-left: auto;
border: 1px solid var(--platform-button-primary-border);
border-radius: 14px 14px 4px;
background: var(--platform-warm-bg);
color: var(--platform-text-base);
}
.game-workbench-chat
.project-supervisor-message-list
> .project-supervisor-process-card {
width: 100%;
}
.game-workbench-chat .agent-runtime-status {
max-height: clamp(120px, 24dvh, 240px);
min-height: 0;
@@ -3482,6 +3482,45 @@ export function registerProjectWorkbenchFoundationTests() {
);
});
it('keeps workbench chat bubbles aligned without shrinking process cards', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const messageListRule =
styles.match(
/\.game-workbench-chat \.project-supervisor-message-list\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const messageRule =
styles.match(
/\.game-workbench-chat \.project-supervisor-message-list \.message\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const userMessageRule =
styles.match(
/\.game-workbench-chat \.project-supervisor-message-list \.message--user\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const processCardRules = Array.from(
styles.matchAll(
/\.game-workbench-chat\s+\.project-supervisor-message-list\s*>\s*\.project-supervisor-process-card\s*\{([^}]*)\}/gs,
),
(match) => match[1],
);
expect(messageListRule).not.toBe('');
expect(messageListRule).not.toContain('display: flex;');
expect(messageListRule).not.toContain('align-items:');
expect(messageRule).toContain('width: fit-content;');
expect(messageRule).toContain('max-width: min(88%, 720px);');
expect(userMessageRule).not.toBe('');
expect(userMessageRule).toContain('margin-left: auto;');
expect(userMessageRule).not.toContain('align-self:');
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);
expect(processCardRules[0]).toContain('width: 100%;');
});
it('enables the run presentation and renders registered images in the resource viewer', async () => {
const manifest = createGameCreationAppManifest(
'workbench-runnable',
@@ -9279,6 +9318,7 @@ export function registerProjectSupervisorSurfaceTests() {
});
const waitingProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(waitingProcessCard.parentElement).toBe(directMessageList);
expect(
within(waitingProcessCard).getByText('已发送消息,正在等待陶泥儿回复'),
).not.toBeNull();
@@ -0,0 +1,148 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
type Rgba = readonly [number, number, number, number];
const themePath = resolve(process.cwd(), 'packages/shared/src/theme.css');
function getCssBlock(source: string, selector: string) {
const selectorIndex = source.indexOf(selector);
expect(selectorIndex, `${selector} should exist`).toBeGreaterThanOrEqual(0);
const openBraceIndex = source.indexOf('{', selectorIndex);
let depth = 0;
for (let index = openBraceIndex; index < source.length; index += 1) {
const char = source[index];
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(openBraceIndex + 1, index);
}
}
}
throw new Error(`${selector} block is not closed`);
}
function getCssVariable(block: string, variableName: string) {
const match = block.match(new RegExp(`${variableName}:\\s*([^;]+);`));
expect(match, `${variableName} should exist`).not.toBeNull();
return match![1].trim();
}
function parseCssColor(source: string): Rgba {
const value = source.trim();
const hex = value.match(/^#([\da-f]{6})$/i);
if (hex) {
return [
Number.parseInt(hex[1].slice(0, 2), 16),
Number.parseInt(hex[1].slice(2, 4), 16),
Number.parseInt(hex[1].slice(4, 6), 16),
1,
];
}
const functional = value.match(/^rgba?\((.*)\)$/i);
if (!functional) {
throw new Error(`unsupported color: ${value}`);
}
const parts = functional[1]
.replace('/', ' ')
.split(/[,\s]+/)
.filter(Boolean);
return [
Number(parts[0]),
Number(parts[1]),
Number(parts[2]),
parts[3] === undefined ? 1 : Number(parts[3]),
];
}
function compositeColor(foreground: Rgba, background: Rgba): Rgba {
const alpha = foreground[3] + background[3] * (1 - foreground[3]);
return [
(foreground[0] * foreground[3] +
background[0] * background[3] * (1 - foreground[3])) /
alpha,
(foreground[1] * foreground[3] +
background[1] * background[3] * (1 - foreground[3])) /
alpha,
(foreground[2] * foreground[3] +
background[2] * background[3] * (1 - foreground[3])) /
alpha,
alpha,
];
}
function contrastRatio(first: Rgba, second: Rgba) {
const luminance = ([red, green, blue]: Rgba) => {
const channels = [red, green, blue].map((channel) => {
const value = channel / 255;
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
};
const firstLuminance = luminance(first);
const secondLuminance = luminance(second);
return (
(Math.max(firstLuminance, secondLuminance) + 0.05) /
(Math.min(firstLuminance, secondLuminance) + 0.05)
);
}
describe('workbench theme contrast', () => {
it('keeps warm user bubbles above WCAG AA text contrast', () => {
const css = readFileSync(themePath, 'utf8');
const light = getCssBlock(css, '.platform-theme--light');
const dark = getCssBlock(css, '.platform-theme--dark');
const bubbleBackground = (theme: string, underlay: Rgba) =>
compositeColor(
parseCssColor(getCssVariable(theme, '--platform-warm-bg')),
compositeColor(
parseCssColor(getCssVariable(theme, '--platform-input-fill')),
underlay,
),
);
// Pure black is darker than any real light-theme workbench underlay.
const lightContrast = contrastRatio(
parseCssColor(getCssVariable(light, '--platform-text-base')),
bubbleBackground(light, [0, 0, 0, 1]),
);
const darkBodyFill = getCssVariable(dark, '--platform-body-fill');
const darkPanelFill = getCssVariable(dark, '--platform-desktop-panel-fill');
expect(darkBodyFill).toContain('rgba(129, 140, 248, 0.2)');
expect(darkBodyFill).toContain('rgba(59, 130, 246, 0.12)');
expect(darkBodyFill).toContain('rgba(34, 211, 238, 0.08)');
expect(darkBodyFill).toContain('#13151c');
expect(darkPanelFill).toContain('rgba(255, 255, 255, 0.05)');
// Conservative upper bound: the brightest body stop, every radial peak,
// and the brightest desktop-panel overlay are composed at full strength.
const darkBrightestUnderlay = compositeColor(
[255, 255, 255, 0.05],
compositeColor(
[129, 140, 248, 0.2],
compositeColor(
[59, 130, 246, 0.12],
compositeColor([34, 211, 238, 0.08], [19, 21, 28, 1]),
),
),
);
const darkContrast = contrastRatio(
parseCssColor(getCssVariable(dark, '--platform-text-base')),
bubbleBackground(dark, darkBrightestUnderlay),
);
expect(contrastRatio([0, 0, 0, 1], [255, 255, 255, 1])).toBeCloseTo(21);
expect(lightContrast).toBeGreaterThanOrEqual(4.5);
expect(darkContrast).toBeGreaterThanOrEqual(4.5);
});
});
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-08-15`项目管理页紧凑表格与桌面可用性收口;资源卡预览、分区布局、非破坏性资源编辑及 Godot 双根合同保持不变)
更新时间:`2026-08-23`右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口;资源卡预览、分区布局、非破坏性资源编辑及 Godot 双根合同保持不变)
## 1. 产品定位
@@ -108,6 +108,7 @@
- 第一批共享 chrome 固定覆盖画布动作按钮、工具栏、工具分组和分隔符。按钮的默认、悬停、键盘焦点、选中、禁用和主次色语义由共享层表达;宿主只提供图标、文案、事件与业务禁用条件。
- 主站账号、钱包、服务端项目、云端素材库和生成面板仍留在网站宿主;客户端本地项目、manifest、Runtime、审批、草稿、生成与正式提交仍留在 Tauri 宿主。共享视觉组件不得读取这些业务事实。
- 客户端 UI 对齐采用“同视觉、同组件、保留四区布局”,不复制主站整页布局。资源总览、运行视图和 Supervisor 对话继续是 Game Agent 工作台独有语义。
- 右侧 Supervisor 对话中,用户消息使用右对齐、最大宽度受限的主题暖色气泡,assistant 消息保持左对齐;消息换行不得产生水平溢出,执行过程卡继续占满消息区可用宽度。气泡正文在 light / dark 平台主题下均须满足 WCAG AA 普通文本 `4.5:1` 对比度。
- 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。
- 普通用户界面不默认展示内部错误码或本机绝对路径,也不展示 External Editor Base URL 或 Developer API Key;官方服务地址由客户端构建固定,远端编辑自动使用当前陶泥儿登录态。仅独立 standalone game-chat release 或显式高级自定义模式在隔离配置页维护 External v1 URL/Key,确需诊断的信息进入受控详情或开发模式。
- 素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择;精修资源继承源用途且不可改。导出格式继续限定 PNG/JPEG/WebP。工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。