From 7cdbc61ffba442b5202c0274446d1fac3821376a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:20:57 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=8F=98=E7=9F=AE=E6=97=B6=E5=AF=B9=E8=AF=9D=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9D=A1=E8=A2=AB=E6=8C=A4=E6=89=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/ai-game-creator-shell/src/styles.css 给 .project-chat-conversation > .project-chat-process-card 补 flex: 0 0 auto:卡片带 overflow: hidden,按 flex 规范该项自动最小尺寸归零,窗口压矮时会先被压缩(实测 300px 高挤到 33px、240px 高 24px,文字被裁),而这一列里只有消息列表该被压缩 - apps/ai-game-creator-shell/src/styles.css 把这条卡片的会话列几何规则从文件末尾移到「面板纵向布局兜底」的 flex 链旁边,内缩与不可压缩合并在同一条规则里,注释说明为什么必须是 0 0 auto - apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts 卡片契约补 flex: 0 0 auto 断言 --- apps/ai-game-creator-shell/src/styles.css | 26 +++++++++++-------- .../appSurface/project-development.suite.ts | 3 +++ 2 files changed, 18 insertions(+), 11 deletions(-) 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/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 () => { -- 2.52.0 From 0daf8cdaadbeabe233eed5fcf60bc96a1bfe05d9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:38:23 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E7=8A=B6=E6=80=81=E6=9D=A1=E6=97=B6=E6=9C=BA?= =?UTF-8?q?=E4=B8=8E=20Markdown=20=E4=BB=A3=E7=A0=81=E5=9D=97=E6=B8=B2?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx 状态条改为读 DirectProjectTurnStatus.displayBusy(本地命令在飞 ∪ 原生在跑),计时起点放宽到最新一个未结束回合:原先只认原生 turnRunning,而 turn.started 要等宿主应答返回才发出,模型首 token 之前那约十秒界面完全没有「正在处理」的交代 - apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx 入参 nativeRunning 更名 turnInFlight 并同步注释 - apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 块级 pre 与 code 补 whitespace-pre-wrap + break-words:窄面板里长行不再把消息拉宽、不再顶出横向滚动条 - apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 新增 normalizeMarkdownFences:把粘在正文行里的 ``` 拆到独立行(模型常写成「…实现细节(game.js):```js」「… }```」),CommonMark 只认整行围栏,粘着的围栏会让正文被当成代码、或代码块不闭合把后续内容一起吞掉;整行/缩进围栏、行内代码、代码里的 ``` 与引用块 / 列表项开头的合法围栏都不受影响 - apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx 新增粘住围栏三种场景与代码块换行契约用例 - apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts 补「turn.started 未到时卡片已出现且已计时」断言 - apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx 同步入参更名 --- .../components/ChatMarkdownMessage/index.tsx | 38 ++++++++- .../chat/DirectProjectChatView.tsx | 8 +- .../DirectProjectConversation.tsx | 12 +-- .../tests/ChatMarkdownMessage.test.tsx | 81 ++++++++++++++++++- .../tests/appSurface/chat-composer.suite.ts | 6 ++ .../tests/directProjectProcessStatus.test.tsx | 4 +- 6 files changed, 135 insertions(+), 14 deletions(-) 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..1a52578f0 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,36 @@ function normalizeMarkdownBlankLines(text: string) { .join(''); } +/** + * 把粘在正文行里的围栏拆到独立行。 + * + * 模型经常把 ``` 直接粘在上一行末尾(`…实现细节(game.js):```js`、`… }````),而 + * CommonMark 只认整行的围栏(最多 3 个空格缩进):粘着的 ``` 退化成正文,于是正文被当成 + * 代码渲染,或者代码块一直不闭合、把后面所有内容一起吞进代码块。 + * + * 判据收得很窄:围栏前面必须是非空白字符,且围栏到行尾只允许剩语言标识(可空)。 + * 这样整行围栏、缩进围栏、行内代码(单个反引号)都不受影响,代码里出现的 ``` 只要后面还有 + * 别的字符(`const s = "```";`)也不会被拆开。 + * + * 引用块与列表项开头的围栏(`> ```js`、`- ```js`)是**合法结构**,不是粘住的:整行跳过, + * 拆开只会把它们从引用块 / 列表项里挪出来。 + */ +const GLUED_FENCE_LINE = + /([^\s`~])[ \t]*(`{3,}|~{3,})([A-Za-z0-9+#._-]*)[ \t]*$/; +const BLOCK_MARKER_LINE = /^\s*(?:[-*+]|\d+[.)]|>)\s/; + +function normalizeMarkdownFences(text: string) { + return text + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => + BLOCK_MARKER_LINE.test(line) + ? line + : line.replace(GLUED_FENCE_LINE, '$1\n$2$3'), + ) + .join('\n'); +} + type MarkdownErrorBoundaryProps = { fallbackText: string; children: ReactNode; @@ -249,7 +279,7 @@ const markdownComponents: Components = { ), pre: ({ children }) => ( -
+    
       
         {children}
       
@@ -260,7 +290,7 @@ const markdownComponents: Components = {
     return isBlock ? (
       
         {children}
       
@@ -338,7 +368,9 @@ function ChatMarkdownMessageImpl({
           streaming ? streamingMarkdownComponents : markdownComponents
         }
       >
-        {preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
+        {preserveBlankLines
+          ? normalizeMarkdownFences(text)
+          : normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
       
     
   );
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(
+       ```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/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}
-- 
2.52.0


From 20a0d08772a51f49776777249cf037abcf2a847a Mon Sep 17 00:00:00 2001
From: Suzumiya 
Date: Thu, 24 Sep 2026 16:40:22 +0800
Subject: [PATCH 3/4] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=81=8A=E5=A4=A9=20Mark?=
 =?UTF-8?q?down=20=E5=9B=B4=E6=A0=8F=E5=BD=92=E4=B8=80=E5=8C=96=E7=9A=84?=
 =?UTF-8?q?=E6=8E=92=E9=9A=9C=E7=BB=8F=E9=AA=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- docs/project-memory/shared-memory/pitfalls.md 新增一条:模型输出会把 ``` 粘在正文行末尾,CommonMark 只认整行围栏,故必须先归一化再解析;同时记录块级 pre 与 code 都要给换行类名
---
 docs/project-memory/shared-memory/pitfalls.md | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index b281c5734..8cac6db7c 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`)都不动——后两者拆开只会把围栏从引用块 / 列表项里挪出来。归一化对 `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 秒,只有这一处不动。
-- 
2.52.0


From 26eb32ea221d299cd19962ea3c4dbb6921db1958 Mon Sep 17 00:00:00 2001
From: Suzumiya 
Date: Thu, 24 Sep 2026 16:56:49 +0800
Subject: [PATCH 4/4] =?UTF-8?q?=E6=8C=89=20review=20=E6=94=B6=E5=8F=A3?=
 =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9D=A1=E4=B8=8E=E5=AF=B9=E8=AF=9D=20Markdo?=
 =?UTF-8?q?wn=20=E7=9A=84=E5=AE=B9=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 围栏归一化补「同一行出现第二段围栏串就跳过」判据:行内代码 `文本 ```x``` ` 的末尾那截曾被当成收场围栏拆开,凭空造出一个开场围栏、把后面的正文全变成代码
- apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 注释补归一化顺序与行内代码判据
- apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts 修正 displayBusy 的说明:原注释仍写着卡片只读 nativeRunning,与现状相反
- apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx 新增行内代码用例(去掉新判据即红)
- docs/project-memory/shared-memory/decision-log.md 新增 2026-09-24 条:记录卡片口径翻转(更正 2026-09-22「卡片口径取保守」那条)并处置其「running 在渲染层就没有消费者」的预言,同时记录状态条几何约束与对话 Markdown 容错口径
- docs/project-memory/shared-memory/pitfalls.md 围栏归一化条补行内代码判据
---
 .../components/ChatMarkdownMessage/index.tsx  | 22 ++++++++++++++-----
 .../controller/useDirectProjectTurnStatus.ts  |  8 ++++---
 .../tests/ChatMarkdownMessage.test.tsx        |  9 ++++++++
 .../shared-memory/decision-log.md             | 10 +++++++++
 docs/project-memory/shared-memory/pitfalls.md |  2 +-
 5 files changed, 41 insertions(+), 10 deletions(-)

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 1a52578f0..bc7c55761 100644
--- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx
+++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx
@@ -49,21 +49,30 @@ function normalizeMarkdownBlankLines(text: string) {
  * 别的字符(`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) =>
-      BLOCK_MARKER_LINE.test(line)
-        ? line
-        : line.replace(GLUED_FENCE_LINE, '$1\n$2$3'),
-    )
+    .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');
 }
 
@@ -368,6 +377,7 @@ function ChatMarkdownMessageImpl({
           streaming ? streamingMarkdownComponents : markdownComponents
         }
       >
+        {/* 顺序有讲究:先拆粘住的围栏,空行压缩的 ` ```…``` ` 配对才认得出真正的代码块。 */}
         {preserveBlankLines
           ? normalizeMarkdownFences(text)
           : normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts
index 05c9f456e..3a2b15d60 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts
@@ -13,11 +13,13 @@ import type {
  * 一次讲清楚,组件只读这一个对象:
  *
  * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started`
- *   已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。
+ *   已到、`turn.completed` 未到)。
  * - `commandInFlight`:**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke →
  *   收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。
- * - `displayBusy`:header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再
- *   接受新的发送。
+ * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,就是两者的并集:
+ *   只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning`:`turn.started`
+ *   要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」
+ *   的交代(2026-09-24 口令)。
  * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。
  *
  * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。
diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx
index 88dbeef88..d2f5d5b4a 100644
--- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx
+++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx
@@ -230,6 +230,15 @@ describe('ChatMarkdownMessage', () => {
     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`、`- ```js`)都不动——后两者拆开只会把围栏从引用块 / 列表项里挪出来。归一化对 `preserveBlankLines`(文件预览)同样生效。
+- **处理(现行口径)**:`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`、粘住的开场 / 收场围栏都渲染成正确结构。
 
-- 
2.52.0