合并 master 到 fix/acl-elevation-single-flight
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m8s
Project CI / Backend tests (pull_request) Successful in 4m8s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Native shell tests (pull_request) Successful in 6m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m48s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m39s
Project CI / Repository checks (pull_request) Successful in 2m14s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled

- 合入 origin/master(dcb4f2a1c):DirectProject 过程卡读秒粒度与样式修复(#508)
- pitfalls.md 冲突按「两边都保留」解决:ACL 单飞条目置顶,其后保留上游 2026-09-24 读秒条目
- 本次合并未改动 ACL 单飞实现(含刚收口的 key 归一化与冷却基准改动)
This commit is contained in:
2026-09-24 12:08:58 +08:00
6 changed files with 134 additions and 93 deletions
+39 -71
View File
@@ -2705,21 +2705,27 @@ textarea {
background: #fff;
}
/* 运行中状态条(「陶泥儿正在处理」+ 右侧已耗时):**单行**。
它渲染在消息列表**外**、紧贴输入盒上方(见文件末尾 `.project-chat-conversation >
.project-chat-process-card` 那条),所以这里只管这一行的几何与配色:左右内缩与上下间距
由宿主的对话面板补。卡片经历过「多行摘要 + 单行标题」的旧面板设计,留下了 78px 最小高度
与 9px 行距;摘要行在 DirectProject 收敛后已不存在,那两个空位只剩空白。
保持 grid(单列)而不是 flex:header 才会被拉满行宽,已耗时的 `margin-left: auto` 才有
余量可以吃掉——flex 下 header 收缩到内容宽,auto 边距恒为 0,耗时只能贴着标题。 */
.project-chat-process-card {
position: relative;
isolation: isolate;
display: grid;
gap: 9px;
min-height: 78px;
box-sizing: border-box;
margin-top: 12px;
padding: 12px 14px;
border: 1px solid #b9d8c5;
padding: 8px 12px;
border: 1px solid var(--platform-subpanel-border, #e1ccbb);
border-radius: 8px;
background: #f1f8f3;
background: var(--platform-neutral-bg, #fffdfa);
overflow: hidden;
}
/* 扫光:用主色而不是白色高光。底色是半透明白,白色高光在它上面几乎看不见,
「正在处理」于是只剩下一枚脉动点,整条状态看着像静止的。 */
.project-chat-process-card::after {
content: '';
position: absolute;
@@ -2729,7 +2735,7 @@ textarea {
background: linear-gradient(
90deg,
transparent,
rgb(255 255 255 / 28%),
color-mix(in srgb, var(--platform-accent) 18%, transparent),
transparent
);
pointer-events: none;
@@ -2752,27 +2758,44 @@ textarea {
.project-chat-process-card > header {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
/* 状态点:主色 + 同色光晕。原先写死绿色(`#2f855a`),工作台里那条想把它换成主色的覆盖规则
挂在 `… .project-chat-message-list > .project-chat-process-card` 上,而卡片渲染在列表**外**,
那条规则一直没生效,于是暖色面板上挂着一枚绿点。 */
.project-chat-process-card > header > span {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: #2f855a;
box-shadow: 0 0 0 4px rgb(47 133 90 / 14%);
background: var(--platform-accent, #2f855a);
box-shadow: 0 0 0 4px
color-mix(in srgb, var(--platform-accent) 16%, transparent);
animation: project-chat-process-pulse 1.2s ease-in-out infinite;
}
/* 标题是这张卡的唯一主信息:它挂在过程层级(12px / 次要色)下,这里把它提到正文强色,
否则整条状态在浅色底上只剩一片灰。 */
.project-chat-process-card > header > strong {
min-width: 0;
overflow: hidden;
color: var(--platform-text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.project-chat-process-elapsed {
margin-left: auto !important;
color: inherit;
margin-left: auto;
color: var(--platform-text-base);
font-size: inherit;
font-style: normal;
font-weight: 500;
white-space: nowrap;
/* 100ms 一跳:等宽数字才不会每跳一次就左右抖一下。 */
font-variant-numeric: tabular-nums;
}
@keyframes project-chat-process-pulse {
@@ -2792,37 +2815,6 @@ textarea {
}
}
.project-chat-process-card p {
margin: 0;
color: inherit;
font-size: inherit;
line-height: 1.55;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
max-height: 1.55em;
white-space: pre-wrap;
overflow-wrap: anywhere;
overflow: hidden;
}
.project-chat-process-card p.is-expanded {
display: block;
max-height: 180px;
overflow: auto;
scrollbar-color: rgb(47 111 75 / 58%) transparent;
}
.project-chat-process-card p.is-expanded::-webkit-scrollbar-track,
.project-chat-process-card p.is-expanded::-webkit-scrollbar-corner {
background: transparent;
}
.game-workbench-chat .project-chat-process-card {
border-color: var(--platform-surface-border);
background: var(--platform-warm-bg);
}
.project-chat-surface .agent-runtime-status {
margin: 0;
}
@@ -9303,13 +9295,6 @@ iframe.preview-frame {
color: var(--platform-text-base);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-message-list
> .project-chat-process-card {
width: 100%;
}
.game-workbench-chat .agent-runtime-status {
max-height: clamp(120px, 24dvh, 240px);
min-height: 0;
@@ -12124,24 +12109,6 @@ button.design-workspace-tree__entry:hover,
white-space: normal;
}
/* 执行过程卡在暖色皮肤下不再用绿色系:改成中性描边 + 暖底,主色只留给状态点与发送钮。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-message-list
> .project-chat-process-card {
border-color: var(--platform-line-soft);
background: var(--platform-neutral-bg);
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-message-list
> .project-chat-process-card
> header
> span {
background: var(--platform-accent);
}
/* 设置浮层(新增):独立 backdrop + dialog,不再往面板下面追加内容。 */
.project-chat-settings-backdrop {
position: fixed;
@@ -12609,14 +12576,15 @@ button.design-workspace-tree__entry:hover,
justify-content: flex-end;
}
/* 过程卡(「任务执行中 / 正在思考中」)现在渲染在消息列表**外**、紧贴输入盒上方(固定在
输入框上面,不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,
才能与消息内容、输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它。 */
/* 过程卡(「陶泥儿正在处理」)现在渲染在消息列表**外**、紧贴输入盒上方(固定在输入框上面,
不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,才能与消息内容、
输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它,与最后一条消息的间距
同样由这里给。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-conversation
> .project-chat-process-card {
margin: 0 16px 8px;
margin: 12px 16px 8px;
}
/* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。
@@ -1,11 +1,7 @@
import {
type RefObject,
type UIEventHandler,
useEffect,
useState,
} from 'react';
import type { RefObject, UIEventHandler } from 'react';
import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent';
import { useLiveNow } from '../../../../../features/project-workspace/useLiveNow';
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
import { formatTurnDuration } from '../ToolCallGroup/toolCallGroupPresentation';
import { DirectProjectTurn } from './DirectProjectTurn';
@@ -82,17 +78,12 @@ export function DirectProjectConversation({
/**
* 运行中回合的已耗时。
*
* 计时器只属于这一小块:秒级 tick 不该把整份回合列表(Markdown、工具组、队列芯片)
* 一起重渲染。
* 时钟订在**这一行**上(叶子节点),走对话侧唯一的 `useLiveNow`(100ms):耗时文案不足
* 一分钟保留一位小数,秒级 tick 会让那个小数位一秒才动一格,看着像卡住不动。
* 100ms 的 tick 也只重建这块文案,不牵动回合列表(Markdown、工具组、队列芯片)。
*/
function DirectTurnElapsed({ startedAt }: { startedAt: number }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (startedAt <= 0) return undefined;
setNow(Date.now());
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [startedAt]);
const now = useLiveNow(startedAt > 0);
if (startedAt <= 0) return null;
return (
<em className="project-chat-process-elapsed">
@@ -72,7 +72,6 @@ describe('AgentMessageContent', () => {
['.agent-tool-call-group'],
[
'.project-chat-process-card',
'.game-workbench-chat .project-chat-process-card',
'.game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-conversation > .project-chat-process-card',
],
]) {
@@ -6336,7 +6336,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styleNumber(editorRule, 'min-height')).toBe(96);
});
it('keeps workbench chat bubbles aligned without shrinking process cards', () => {
it('keeps workbench chat bubbles aligned and the process card inset with them', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
@@ -6355,7 +6355,7 @@ export function registerProjectWorkbenchFoundationTests() {
)?.[1] ?? '';
const processCardRules = Array.from(
styles.matchAll(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-message-list\s*>\s*\.project-chat-process-card\s*\{([^}]*)\}/gs,
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-conversation\s*>\s*\.project-chat-process-card\s*\{([^}]*)\}/gs,
),
(match) => match[1],
);
@@ -6371,10 +6371,10 @@ 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);');
// 执行过程卡有两条同选择器规则:第一条是几何(`width: 100%`),后面那条是 Codex 暖色
// 皮肤下的配色(把基础规则的绿系换成中性描边 + 暖底)。承重的是几何那条。
expect(processCardRules.length).toBeGreaterThanOrEqual(1);
expect(processCardRules[0]).toContain('width: 100%;');
// 过程卡渲染在消息列表的**兄弟**位置(列表外,紧贴输入盒上方),拿不到列表的
// `padding: 14px 16px 0`,左右内缩与上下间距只能自己给:左右必须与列表的 16px 对齐。
expect(processCardRules.length).toBe(1);
expect(processCardRules[0]).toContain('margin: 12px 16px 8px;');
});
it('enables the run presentation and renders registered images in the resource viewer', async () => {
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { cleanup, render } from '@testing-library/react';
import { act, createRef } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import { DirectProjectConversation } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation';
afterEach(() => {
cleanup();
});
const STARTED_AT = 1_800_000_000_000;
/**
* 读秒粒度:走对话侧唯一的 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`)。
*
* 状态条的耗时文案不足一分钟保留一位小数,刷新就必须是 100ms——按 1 秒一跳时,用户看到的
* 是一块「带小数却一格一格跳」的表,像卡住不动。这里用假时钟钉住粒度:把时钟改回 1000ms
* 时第二步即红。
*/
test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以 0.1 秒递增', () => {
vi.useFakeTimers();
try {
vi.setSystemTime(STARTED_AT);
const view = render(
<DirectProjectConversation
turns={[]}
messagesRef={createRef<HTMLDivElement>()}
historyHasMore={false}
nativeRunning
activeTurnStartedAt={STARTED_AT}
onLoadEarlierHistory={() => undefined}
onScroll={() => undefined}
/>,
);
const elapsed = () => view.getByText(/^已耗时 /u).textContent;
expect(elapsed()).toBe('已耗时 0.0秒');
act(() => {
vi.advanceTimersByTime(100);
});
expect(elapsed()).toBe('已耗时 0.1秒');
// 1 秒一跳的实现会停在上面的 0.1 秒:再走 400ms 必须继续往上加。
act(() => {
vi.advanceTimersByTime(400);
});
expect(elapsed()).toBe('已耗时 0.5秒');
} finally {
vi.useRealTimers();
}
});
/** 回合不在跑时不订阅时钟:否则空转的 tick 会持续重建状态条所在的那块视图。 */
test('没有运行中的回合时不订阅时钟', () => {
vi.useFakeTimers();
const spy = vi.spyOn(globalThis, 'setInterval');
try {
render(
<DirectProjectConversation
turns={[]}
messagesRef={createRef<HTMLDivElement>()}
historyHasMore={false}
nativeRunning={false}
activeTurnStartedAt={0}
onLoadEarlierHistory={() => undefined}
onScroll={() => undefined}
/>,
);
expect(spy).not.toHaveBeenCalled();
} finally {
spy.mockRestore();
vi.useRealTimers();
}
});