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}