diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 543351e25..bc7c55761 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -37,6 +37,45 @@ function normalizeMarkdownBlankLines(text: string) { .join(''); } +/** + * 把粘在正文行里的围栏拆到独立行。 + * + * 模型经常把 ``` 直接粘在上一行末尾(`…实现细节(game.js):```js`、`… }````),而 + * CommonMark 只认整行的围栏(最多 3 个空格缩进):粘着的 ``` 退化成正文,于是正文被当成 + * 代码渲染,或者代码块一直不闭合、把后面所有内容一起吞进代码块。 + * + * 判据收得很窄:围栏前面必须是非空白字符,且围栏到行尾只允许剩语言标识(可空)。 + * 这样整行围栏、缩进围栏、行内代码(单个反引号)都不受影响,代码里出现的 ``` 只要后面还有 + * 别的字符(`const s = "```";`)也不会被拆开。 + * + * 引用块与列表项开头的围栏(`> ```js`、`- ```js`)是**合法结构**,不是粘住的:整行跳过, + * 拆开只会把它们从引用块 / 列表项里挪出来。同一行出现第二段围栏串(行内代码 + * ``文本 ```x``` ``)时同样跳过——末尾那截不是收场围栏。 + */ +const GLUED_FENCE_LINE = + /([^\s`~])[ \t]*(`{3,}|~{3,})([A-Za-z0-9+#._-]*)[ \t]*$/; +const BLOCK_MARKER_LINE = /^\s*(?:[-*+]|\d+[.)]|>)\s/; +const FENCE_RUN_TOKEN = /(`{3,}|~{3,})/g; + +function normalizeMarkdownFences(text: string) { + return text + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => { + if (BLOCK_MARKER_LINE.test(line)) { + return line; + } + // 行里还有第二条围栏串时不动:那是行内代码(`文本 ```x``` `),末尾那截不是收场围栏。 + // 拆开它会凭空多出一个开场围栏,把后面的正文全变成代码。 + const runs = line.match(FENCE_RUN_TOKEN); + if (runs && runs.length > 1) { + return line; + } + return line.replace(GLUED_FENCE_LINE, '$1\n$2$3'); + }) + .join('\n'); +} + type MarkdownErrorBoundaryProps = { fallbackText: string; children: ReactNode; @@ -249,7 +288,7 @@ const markdownComponents: Components = { ), pre: ({ children }) => ( -
+    
       
         {children}
       
@@ -260,7 +299,7 @@ const markdownComponents: Components = {
     return isBlock ? (
       
         {children}
       
@@ -338,7 +377,10 @@ function ChatMarkdownMessageImpl({
           streaming ? streamingMarkdownComponents : markdownComponents
         }
       >
-        {preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
+        {/* 顺序有讲究:先拆粘住的围栏,空行压缩的 ` ```…``` ` 配对才认得出真正的代码块。 */}
+        {preserveBlankLines
+          ? normalizeMarkdownFences(text)
+          : normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
       
     
   );
diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css
index 368ddf987..4b4ed2ebc 100644
--- a/apps/ai-game-creator-shell/src/styles.css
+++ b/apps/ai-game-creator-shell/src/styles.css
@@ -12282,6 +12282,21 @@ button.design-workspace-tree__entry:hover,
   flex: 1 1 auto;
 }
 
+/* 过程卡(「陶泥儿正在处理」)渲染在消息列表**外**、紧贴输入盒上方(固定在输入框上面,
+   不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,才能与消息内容、
+   输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它,与最后一条消息的间距
+   同样由这里给。
+   `flex: 0 0 auto` 是这条链上的承重项:卡片带 `overflow: hidden`,flex 项的自动最小尺寸
+   因此归零,窗口变矮时它会先被挤扁、文字被裁掉(实测高度 300px 时压到 33px、240px 时
+   24px),而这一列里只有消息列表该被压缩。 */
+.game-workbench-chat
+  .project-chat-surface.is-direct-codex
+  .project-chat-conversation
+  > .project-chat-process-card {
+  flex: 0 0 auto;
+  margin: 12px 16px 8px;
+}
+
 /* ============================================================
    工具调用折叠块(2026-09):一回合一个块,Codex 风格
    ============================================================
@@ -12576,17 +12591,6 @@ button.design-workspace-tree__entry:hover,
   justify-content: flex-end;
 }
 
-/* 过程卡(「陶泥儿正在处理」)现在渲染在消息列表**外**、紧贴输入盒上方(固定在输入框上面,
-   不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,才能与消息内容、
-   输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它,与最后一条消息的间距
-   同样由这里给。 */
-.game-workbench-chat
-  .project-chat-surface.is-direct-codex
-  .project-chat-conversation
-  > .project-chat-process-card {
-  margin: 12px 16px 8px;
-}
-
 /* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。
    窄面板里一行塞不下(汇总会被截断、"进行中"还会折成两行),拆成两行后每段都有自己的宽度。 */
 .game-workbench-chat
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
index 2787dab0c..a2c70915b 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
@@ -171,8 +171,12 @@ export function DirectProjectChatView({
     turnBusy,
     turns: directTurns,
   });
+  // 状态条的起点只认**未结束**的最新一轮:`awaiting-start`(本地已发出、宿主还没确认)
+  // 也有用户发送时间,只读 `running` 会让卡片在模型首 token 之前根本不出现。
+  // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮,`awaiting-start` 也只判最新一轮。
+  const latestTurn = directTurns.at(-1) ?? null;
   const activeTurnStartedAt =
-    directTurns.find((turn) => turn.state === 'running')?.startedAt ?? 0;
+    latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0;
   const statusText =
     runtimeNotice ||
     statusNotice ||
@@ -248,7 +252,7 @@ export function DirectProjectChatView({
           turns={directTurns}
           messagesRef={messagesRef}
           historyHasMore={historyHasMore}
-          nativeRunning={turnStatus.nativeRunning}
+          turnInFlight={turnStatus.displayBusy}
           activeTurnStartedAt={activeTurnStartedAt}
           onLoadEarlierHistory={() => void loadEarlierHistory()}
           onScroll={handleScroll}
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 4ab6f8e03..90dae2d40 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
@@ -15,7 +15,7 @@ export function DirectProjectConversation({
   turns,
   messagesRef,
   historyHasMore,
-  nativeRunning,
+  turnInFlight,
   activeTurnStartedAt,
   onLoadEarlierHistory,
   onScroll,
@@ -24,10 +24,12 @@ export function DirectProjectConversation({
   messagesRef: RefObject;
   historyHasMore: boolean;
   /**
-   * 原生回合是否在跑(reducer 的 `turnRunning`):只决定这张"正在处理"卡片。
-   * 本地命令在飞但原生还没认领的窗口见 `DirectProjectTurnStatus`。
+   * 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。
+   *
+   * 只认原生 `turnRunning` 会让卡片在「命令已发出、`turn.started` 未到」的空窗里不出现——
+   * 模型首 token 之前那段(实测约十秒)界面就没有任何「正在处理」的交代。
    */
-  nativeRunning: boolean;
+  turnInFlight: boolean;
   activeTurnStartedAt: number;
   onLoadEarlierHistory: () => void;
   onScroll: UIEventHandler;
@@ -53,7 +55,7 @@ export function DirectProjectConversation({
           
         ))}
       
-      {nativeRunning ? (
+      {turnInFlight ? (
          {
     ).toBeNull();
   });
 
-  it('无语言标记的多行围栏代码仍使用代码块样式', () => {
+  it('无语言标记的多行围栏代码仍使用代码块样式,且允许自动换行', () => {
     const { container } = render(
        {
     );
 
     const code = container.querySelector('pre code');
-    expect(code?.className).toContain('whitespace-pre');
+    const pre = container.querySelector('pre');
     expect(code?.className).not.toContain('rounded');
+    // 代码块必须自动换行:窄面板(280–420px)里长行会把消息拉宽并顶出横向滚动条,
+    // `pre` 与块内 `code` 各自都写过 `white-space`,两处都要给。
+    expect(code?.className).toContain('whitespace-pre-wrap');
+    expect(code?.className).toContain('break-words');
+    expect(pre?.className).toContain('whitespace-pre-wrap');
+    expect(pre?.className).toContain('break-words');
+  });
+
+  it('围栏粘在正文行末尾时仍开在正确位置', () => {
+    const { container } = render(
+      ,
+    );
+
+    // 开场围栏不拆开的话,整行会退化成「一段正文里跟着 ```js」,
+    // 代码块根本不成立、后面的正文又会被当成代码。
+    expect(container.querySelector('pre code')?.textContent).toBe(
+      'const answer = 42;\n',
+    );
+    expect(container.querySelector('pre')?.textContent).not.toContain(
+      '实现细节',
+    );
+  });
+
+  it('收场围栏粘在代码行末尾时不再把后续正文吞进代码块', () => {
+    const { container } = render(
+      ,
+    );
+
+    const code = container.querySelector('pre code');
+    expect(code?.textContent).toBe('const a = 1;\n}); }\n');
+    expect(code?.textContent).not.toContain('```');
+    expect(container.querySelectorAll('pre')).toHaveLength(1);
+    // 围栏之后的段落回到正文,而不是继续当代码渲染。
+    expect(container.querySelector('pre')?.textContent).not.toContain(
+      '后面还是正文',
+    );
+    expect(container.textContent).toContain('后面还是正文');
+  });
+
+  it('不拆开代码里出现的 ``` 与行内代码', () => {
+    const { container } = render(
+      ,
+    );
+
+    expect(container.querySelector('pre code')?.textContent).toBe(
+      'const s = "```";\n',
+    );
+    expect(container.querySelectorAll('pre')).toHaveLength(1);
+    expect(container.textContent).toContain('行内 code 保持原样');
+  });
+
+  it('行内代码里的三段反引号保持原样,不凭空造出围栏', () => {
+    const { container } = render(
+      ,
+    );
+
+    expect(container.querySelector('p code')?.textContent).toBe('x');
+    expect(container.querySelectorAll('pre')).toHaveLength(0);
+  });
+
+  it('引用块与列表项开头的围栏是合法结构,不做拆分', () => {
+    const { container } = render(
+       ```js\n> const quoted = 1;\n> ```\n\n- ```js\n  const listed = 2;\n  ```\n'
+        }
+      />,
+    );
+
+    // 拆开这两行只会把围栏从引用块 / 列表项里挪出来。
+    expect(
+      container.querySelector('blockquote pre code')?.textContent,
+    ).toContain('const quoted = 1;');
+    expect(container.querySelector('li pre code')?.textContent).toContain(
+      'const listed = 2;',
+    );
   });
 
   it('保留行内代码中的 HTML 字面量', () => {
diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
index 205bd0fca..52b132d5e 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
@@ -549,6 +549,12 @@ export function registerChatComposerControlTests() {
     });
     expect(within(surface).queryByText(/本轮结束于/)).toBeNull();
     expect(within(surface).queryByTestId('turn-usage')).toBeNull();
+    // 卡片从「本地命令在飞」起就得出现:只认原生 turn.started 的话,模型首 token 之前
+    // 那段(实测约十秒)界面完全不说"正在处理"。
+    expect(
+      within(surface).getAllByText('陶泥儿正在处理').length,
+    ).toBeGreaterThan(0);
+    expect(within(surface).getByText(/^已耗时 /u)).not.toBeNull();
 
     await act(async () => {
       pending[0]?.resolve('回复');
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 1d5ac535f..c12e7f92b 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
@@ -6373,8 +6373,11 @@ export function registerProjectWorkbenchFoundationTests() {
     expect(userMessageRule).toContain('color: var(--platform-text-base);');
     // 过程卡渲染在消息列表的**兄弟**位置(列表外,紧贴输入盒上方),拿不到列表的
     // `padding: 14px 16px 0`,左右内缩与上下间距只能自己给:左右必须与列表的 16px 对齐。
+    // 同一列里它是定高条目:窗口变矮时只有消息列表可以被压缩,卡片带 `overflow: hidden`
+    // 时自动最小尺寸归零,少了 `flex: 0 0 auto` 就会被挤扁、文字被裁掉。
     expect(processCardRules.length).toBe(1);
     expect(processCardRules[0]).toContain('margin: 12px 16px 8px;');
+    expect(processCardRules[0]).toContain('flex: 0 0 auto;');
   });
 
   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
index 396c75f62..99ed6ae34 100644
--- a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx
+++ b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx
@@ -27,7 +27,7 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以
         turns={[]}
         messagesRef={createRef()}
         historyHasMore={false}
-        nativeRunning
+        turnInFlight
         activeTurnStartedAt={STARTED_AT}
         onLoadEarlierHistory={() => undefined}
         onScroll={() => undefined}
@@ -61,7 +61,7 @@ test('没有运行中的回合时不订阅时钟', () => {
         turns={[]}
         messagesRef={createRef()}
         historyHasMore={false}
-        nativeRunning={false}
+        turnInFlight={false}
         activeTurnStartedAt={0}
         onLoadEarlierHistory={() => undefined}
         onScroll={() => undefined}
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index b7aef73f4..b63bfbef2 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -1,5 +1,15 @@
 # 决策记录
 
+## 2026-09-24 DirectProject 状态条口径翻转、几何约束与对话 Markdown 容错
+
+- 背景:AGC DirectProject 对话区底部的「陶泥儿正在处理 / 已耗时 12.4秒」状态条同时退化三处:① 读秒 1 秒一跳(耗时文案不足一分钟显示一位小数,小数位却一秒才动一格);② 窗口压矮时被挤扁(300px 高压到 33px、240px 时 24px,文字被 `overflow: hidden` 裁掉);③ `turn.started` 之前(模型首 token 前,实测约十秒)整条卡片不出现,界面没有任何「正在处理」的交代。同批还修了对话 Markdown 的两处代码块问题(不换行把消息拉宽、粘在正文行里的围栏导致代码块解析错位)。
+- 决策(卡片口径翻转,**更正** 2026-09-22「卡片口径取保守」):卡片与已耗时起点改读 `displayBusy`(本地命令在飞 ∪ 原生已确认在跑)与「最新一个**未结束**回合的用户发送时间」。理由:`turn.started` 要等宿主应答返回才发出,只认原生真相会让首 token 之前那段没有交代;窗口期这一轮确实已经交给宿主(本地命令在飞),文案不虚报「宿主已在跑」之外的东西。
+- 决策(那条预言的处置):2026-09-22 那条写「若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,应把投影压成 `unfinished: boolean`」。本次改完后投影三态**仍有**消费者——`DirectProjectTurn` 用 `state !== 'finished'` 做否定式判断、`state === 'running'` 挑流式正文,状态条也用 `state !== 'finished'` 定起点——所以不动 `DirectChatTurnState`,也不压缩成布尔。
+- 决策(状态条几何):卡片在 `.project-chat-conversation` 这条定高 flex 列里必须 `flex: 0 0 auto`。它带 `overflow: hidden`,按 flex 规范该项的自动最小尺寸归零,是这条链上唯一还能被压缩的项;压缩只能由消息列表吸收。同一选择器只保留一条规则(几何 + 不可压缩),不留两份。
+- 决策(对话 Markdown 对模型输出的容错):解析前先 `normalizeMarkdownFences` 再压缩空行;代码块 `pre` 与块内 `code` 各自都给 `whitespace-pre-wrap` + `break-words`。细则与判据见 `pitfalls.md` 同日两条。
+- 影响面:`apps/ai-game-creator-shell/src/{styles.css,components/ChatMarkdownMessage/index.tsx,view/project-development/chat/{DirectProjectChatView.tsx,components/DirectProjectConversation/DirectProjectConversation.tsx,controller/useDirectProjectTurnStatus.ts}}`;用例 `tests/{ChatMarkdownMessage.test.tsx,directProjectProcessStatus.test.tsx,appSurface/{chat-composer.suite.ts,project-development.suite.ts}}`。
+- 验证:`npx vitest run apps/ai-game-creator-shell/tests` 186 passed / 1 skipped(1874 条里 1860 passed / 14 skipped);真实 Chromium 夹具复核读秒 100ms、窗口 900→220px 高度下卡片恒为 36px 不被裁、代码块换行与两类粘住围栏;变异验证(读秒改回 1000ms、删 `flex: 0 0 auto`、卡片退回 `nativeRunning`、停掉围栏归一化、换行类名退回)逐条变红。
+
 ## 策划 V1/V2 退役的现行边界
 
 - 旧策划 V1 和 Runtime V2 均已删除,当前策划入口统一使用独立 Design Agent。V1 被 V2 接替只描述历史过程,不表示 V2 仍在使用。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index b281c5734..de4f438cb 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -2,6 +2,14 @@
 
 > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。
 
+## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析
+
+- **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。
+- **原因**:CommonMark 只认**整行**的围栏(最多 3 个空格缩进)。模型经常把 ``` 直接粘在上一行末尾,那个 ``` 退化成行内文本:开场围栏不成立(后面的正文被当成代码)、收场围栏不生效(代码块不闭合,吞掉剩余内容)。`ChatMarkdownMessage` 原先只做空行压缩,没有这一步归一化。
+- **处理(现行口径)**:`normalizeMarkdownFences` 在解析前把「围栏前是非空白字符、围栏到行尾只剩语言标识(可空)」的行拆成两行。判据刻意收窄:整行 / 缩进围栏、行内代码(单个反引号)、代码里出现的 ``` (`const s = "```";`)、引用块 / 列表项开头的合法围栏(`> ```js`、`- ```js`)以及同一行里出现第二段围栏串的行内代码(``文本 ```x``` ``)都不动——后三者拆开只会把围栏从引用块 / 列表项里挪出来,或者凭空造出一个开场围栏。归一化对 `preserveBlankLines`(文件预览)同样生效。
+- **同时**:块级 `pre` 与块内 `code` 都要给 `whitespace-pre-wrap` + `break-words`(两处各写过 `white-space`,只改一处不换行),否则窄面板里长行会把消息拉宽、顶出横向滚动条。
+- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx`(去掉归一化或换行类名即红);真实 Chromium 夹具里长代码行 `horizontalOverflow: false`、粘住的开场 / 收场围栏都渲染成正确结构。
+
 ## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格
 
 - **现象**:AGC DirectProject 对话区底部那条「陶泥儿正在处理 / 已耗时 12.4秒」的状态条,小数位一秒才动一格,看着像读数卡住;同一屏里工具卡片的耗时与资源生成侧栏的读秒都在正常走 0.1 秒,只有这一处不动。