diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index a08b2edde..368ddf987 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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; } /* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx index fec7a00e4..4ab6f8e03 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx @@ -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 ( diff --git a/apps/ai-game-creator-shell/tests/AgentMessageContent.test.tsx b/apps/ai-game-creator-shell/tests/AgentMessageContent.test.tsx index 9466cd0dd..caeabb4c7 100644 --- a/apps/ai-game-creator-shell/tests/AgentMessageContent.test.tsx +++ b/apps/ai-game-creator-shell/tests/AgentMessageContent.test.tsx @@ -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', ], ]) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index c9ece5b3b..1d5ac535f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -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 () => { diff --git a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx new file mode 100644 index 000000000..396c75f62 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx @@ -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( + ()} + 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( + ()} + historyHasMore={false} + nativeRunning={false} + activeTurnStartedAt={0} + onLoadEarlierHistory={() => undefined} + onScroll={() => undefined} + />, + ); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + vi.useRealTimers(); + } +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7738d5a9f..6e4a24d24 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -9,6 +9,14 @@ - **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。 - **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 +## 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)。