修复 DirectProject 对话状态条时机与 Markdown 代码块渲染
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- 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 同步入参更名
This commit is contained in:
2026-09-24 16:38:23 +08:00
parent 7cdbc61ffb
commit 0daf8cdaad
6 changed files with 135 additions and 14 deletions
@@ -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 = {
</blockquote>
),
pre: ({ children }) => (
<pre className="m-0 mt-2 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5 first:mt-0">
<pre className="m-0 mt-2 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5 break-words whitespace-pre-wrap first:mt-0">
<CodeBlockContext.Provider value={true}>
{children}
</CodeBlockContext.Provider>
@@ -260,7 +290,7 @@ const markdownComponents: Components = {
return isBlock ? (
<code
{...props}
className={`agc-markdown-code font-mono whitespace-pre ${className ?? ''}`}
className={`agc-markdown-code font-mono break-words whitespace-pre-wrap ${className ?? ''}`}
>
{children}
</code>
@@ -338,7 +368,9 @@ function ChatMarkdownMessageImpl({
streaming ? streamingMarkdownComponents : markdownComponents
}
>
{preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
{preserveBlankLines
? normalizeMarkdownFences(text)
: normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
</ReactMarkdown>
</MarkdownErrorBoundary>
);
@@ -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}
@@ -15,7 +15,7 @@ export function DirectProjectConversation({
turns,
messagesRef,
historyHasMore,
nativeRunning,
turnInFlight,
activeTurnStartedAt,
onLoadEarlierHistory,
onScroll,
@@ -24,10 +24,12 @@ export function DirectProjectConversation({
messagesRef: RefObject<HTMLDivElement | null>;
historyHasMore: boolean;
/**
* 原生回合是否在跑(reducer 的 `turnRunning`):只决定这张"正在处理"卡片
* 本地命令在飞但原生还没认领的窗口见 `DirectProjectTurnStatus`。
* 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)
*
* 只认原生 `turnRunning` 会让卡片在「命令已发出、`turn.started` 未到」的空窗里不出现——
* 模型首 token 之前那段(实测约十秒)界面就没有任何「正在处理」的交代。
*/
nativeRunning: boolean;
turnInFlight: boolean;
activeTurnStartedAt: number;
onLoadEarlierHistory: () => void;
onScroll: UIEventHandler<HTMLDivElement>;
@@ -53,7 +55,7 @@ export function DirectProjectConversation({
<DirectProjectTurn key={turn.key} turn={turn} />
))}
</div>
{nativeRunning ? (
{turnInFlight ? (
<AgentMessageContent
as="section"
tone="process"
@@ -159,7 +159,7 @@ describe('ChatMarkdownMessage', () => {
).toBeNull();
});
it('无语言标记的多行围栏代码仍使用代码块样式', () => {
it('无语言标记的多行围栏代码仍使用代码块样式,且允许自动换行', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
@@ -168,8 +168,85 @@ describe('ChatMarkdownMessage', () => {
);
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(
<ChatMarkdownMessage
role="assistant"
text={'实现细节(game.js):```js\nconst answer = 42;\n```\n'}
/>,
);
// 开场围栏不拆开的话,整行会退化成「一段正文里跟着 ```js」,
// 代码块根本不成立、后面的正文又会被当成代码。
expect(container.querySelector('pre code')?.textContent).toBe(
'const answer = 42;\n',
);
expect(container.querySelector('pre')?.textContent).not.toContain(
'实现细节',
);
});
it('收场围栏粘在代码行末尾时不再把后续正文吞进代码块', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'```js\nconst a = 1;\n}); }```\n\n后面还是正文\n'}
/>,
);
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(
<ChatMarkdownMessage
role="assistant"
text={'```js\nconst s = "```";\n```\n\n行内 `code` 保持原样\n'}
/>,
);
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(
<ChatMarkdownMessage
role="assistant"
text={
'> ```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 字面量', () => {
@@ -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('回复');
@@ -27,7 +27,7 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以
turns={[]}
messagesRef={createRef<HTMLDivElement>()}
historyHasMore={false}
nativeRunning
turnInFlight
activeTurnStartedAt={STARTED_AT}
onLoadEarlierHistory={() => undefined}
onScroll={() => undefined}
@@ -61,7 +61,7 @@ test('没有运行中的回合时不订阅时钟', () => {
turns={[]}
messagesRef={createRef<HTMLDivElement>()}
historyHasMore={false}
nativeRunning={false}
turnInFlight={false}
activeTurnStartedAt={0}
onLoadEarlierHistory={() => undefined}
onScroll={() => undefined}