修复 AGC DirectProject 运行中过程卡的读秒粒度与样式
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m8s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 53s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m21s
Project CI / Backend tests (pull_request) Successful in 4m12s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m2s
Project CI / Frontend tests (pull_request) Successful in 2m11s
Project CI / Repository checks (pull_request) Successful in 2m33s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m1s
Project CI / Native shell tests (pull_request) Successful in 6m27s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m8s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 53s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m21s
Project CI / Backend tests (pull_request) Successful in 4m12s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m2s
Project CI / Frontend tests (pull_request) Successful in 2m11s
Project CI / Repository checks (pull_request) Successful in 2m33s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m1s
Project CI / Native shell tests (pull_request) Successful in 6m27s
- apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx 的 DirectTurnElapsed 改用对话侧共享时钟 useLiveNow(100ms),删掉组件自带的 1000ms setInterval 与 now state:耗时文案不足一分钟保留一位小数,秒级 tick 会让小数位一秒才动一格 - apps/ai-game-creator-shell/src/styles.css 重写 .project-chat-process-card:去掉旧面板遗留的 78px 最小高度与 9px 行距,收敛为单行状态条(padding 8px 12px) - apps/ai-game-creator-shell/src/styles.css 状态点与扫光改走 --platform-accent(原先写死绿色),标题提到 --platform-text-strong,耗时改 --platform-text-base 并加 tabular-nums - apps/ai-game-creator-shell/src/styles.css 删除两条永不命中的选择器(卡片渲染在消息列表外,规则却写着 …message-list > .project-chat-process-card)与已无 <p> 渲染的多行摘要样式 - apps/ai-game-creator-shell/src/styles.css 过程卡与最后一条消息之间补 12px 上间距,原先 margin 简写把它清零 - apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx 新增读秒粒度回归用例与「无运行中回合不订阅时钟」用例 - apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts 与 tests/AgentMessageContent.test.tsx 的选择器契约同步到现行 DOM 结构 - docs/project-memory/shared-memory/pitfalls.md 记录「刷新粒度必须与显示精度同格」这条复发坑
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。
|
||||
|
||||
+6
-15
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格
|
||||
|
||||
- **现象**:AGC DirectProject 对话区底部那条「陶泥儿正在处理 / 已耗时 12.4秒」的状态条,小数位一秒才动一格,看着像读数卡住;同一屏里工具卡片的耗时与资源生成侧栏的读秒都在正常走 0.1 秒,只有这一处不动。
|
||||
- **原因**:耗时文案不足一分钟保留一位小数(`formatElapsedDuration`),刷新就必须是 100ms。这次改动方向本身是对的——把 clock 从整个聊天视图下移到耗时那一行,但顺手在组件里另写了一份 `setInterval(..., 1000)`,绕过了对话侧唯一的 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`);`team-conventions.md` 里「运行时用 100ms 叶子时钟刷新一位小数、终态冻结」这条约定当时已经写好,改动没有对齐它。
|
||||
- **处理(现行口径)**:耗时文案的刷新一律走 `useLiveNow`,不在视图组件里另起 interval;tick 只订在显示耗时的那一行(叶子节点),不能落在整块面板或整份回合列表上。可机检的判据是「不足一分钟的耗时必须每 100ms 递增一次小数位」。
|
||||
- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx`(把 `LIVE_TIMER_TICK_MS` 临时改回 1000 时第一步即红);真实浏览器里 600ms 内文案从 `18.0秒` 走到 `18.6秒`。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx`、`apps/ai-game-creator-shell/src/features/project-workspace/useLiveNow.ts`、`apps/ai-game-creator-shell/src/styles.css`。
|
||||
|
||||
## 策划回复的重复终态不能重新启动伪流式
|
||||
|
||||
策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
||||
|
||||
Reference in New Issue
Block a user