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`、粘住的开场 / 收场围栏都渲染成正确结构。