Feat/AGC对话渲染markdown #281

Merged
kdletters merged 31 commits from feat/agc-markdown-render into master 2026-09-08 22:03:37 +08:00
16 changed files with 804 additions and 132 deletions
@@ -0,0 +1,281 @@
import type { ErrorInfo, ReactNode } from 'react';
import {
Children,
Component,
createContext,
isValidElement,
useContext,
} from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
export type ChatMarkdownMessageProps = {
text: string;
role: 'assistant' | 'user';
streaming?: boolean;
};
type MarkdownErrorBoundaryProps = {
fallbackText: string;
children: ReactNode;
};
type MarkdownErrorBoundaryState = {
hasError: boolean;
};
export class MarkdownErrorBoundary extends Component<
MarkdownErrorBoundaryProps,
MarkdownErrorBoundaryState
> {
state: MarkdownErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): MarkdownErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
// Keep the original message visible without logging its potentially sensitive content.
const errorName =
error instanceof Error && error.name ? error.name : 'UnknownError';
console.error('[chat-markdown] render failed', {
errorName,
hasComponentStack: Boolean(errorInfo.componentStack?.trim()),
});
}
componentDidUpdate(prevProps: MarkdownErrorBoundaryProps) {
if (
this.state.hasError &&
prevProps.fallbackText !== this.props.fallbackText
) {
this.setState({ hasError: false });
}
}
render() {
if (this.state.hasError) {
return (
<span className="whitespace-pre-wrap break-words">
{this.props.fallbackText}
</span>
);
}
return this.props.children;
}
}
const ListDepthContext = createContext(0);
const ListKindContext = createContext<'unordered' | 'ordered' | null>(null);
type ListItemParagraphPosition = 'first' | 'continuation';
const ListItemContext = createContext<ListItemParagraphPosition | null>(null);
function MarkdownUnorderedList({ children }: { children?: ReactNode }) {
const depth = useContext(ListDepthContext);
return (
<ListDepthContext.Provider value={depth + 1}>
<ListKindContext.Provider value="unordered">
<ul
className={`m-0 mt-2 list-none space-y-1 first:mt-0 ${
depth > 0 ? 'pl-4' : 'pl-0'
}`}
>
{children}
</ul>
</ListKindContext.Provider>
</ListDepthContext.Provider>
);
}
function MarkdownOrderedList({
children,
start,
}: {
children?: ReactNode;
start?: number;
}) {
const depth = useContext(ListDepthContext);
return (
<ListDepthContext.Provider value={depth + 1}>
<ListKindContext.Provider value="ordered">
<ol
start={start}
className="m-0 mt-2 list-decimal space-y-1 pl-5 first:mt-0"
>
{children}
</ol>
</ListKindContext.Provider>
</ListDepthContext.Provider>
);
}
function MarkdownParagraph({ children }: { children?: ReactNode }) {
const paragraphPosition = useContext(ListItemContext);
return (
<p
className={`m-0 break-words ${
paragraphPosition === 'first'
? 'inline'
: paragraphPosition === 'continuation'
? 'mt-2'
: 'mt-2 first:mt-0'
}`}
>
{children}
</p>
);
}
function StreamingMarkdownParagraph({ children }: { children?: ReactNode }) {
const paragraphPosition = useContext(ListItemContext);
return (
<p
className={`m-0 break-words opacity-95 ${
paragraphPosition === 'first'
? 'inline'
: paragraphPosition === 'continuation'
? 'mt-2'
: 'mt-2 first:mt-0'
}`}
>
{children}
</p>
);
}
function MarkdownListItem({ children }: { children?: ReactNode }) {
const listKind = useContext(ListKindContext);
let paragraphIndex = 0;
const childrenWithParagraphContext = Children.map(
children,
(child, index) => {
if (
isValidElement(child) &&
(child.type === MarkdownParagraph ||
child.type === StreamingMarkdownParagraph)
) {
const position: ListItemParagraphPosition =
paragraphIndex++ === 0 ? 'first' : 'continuation';
return (
<ListItemContext.Provider key={child.key ?? index} value={position}>
{child}
</ListItemContext.Provider>
);
}
return child;
},
);
return (
<li className="break-words whitespace-normal">
{listKind === 'unordered' ? '- ' : null}
{childrenWithParagraphContext}
</li>
);
}
const markdownComponents: Components = {
// TODO: 产品确认安全外链策略后,再将链接文本恢复为可点击元素。
a: ({ children }) => children,
img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'),
h1: ({ children }) => (
<h1 className="m-0 mt-4 !text-xl font-bold first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="m-0 mt-4 !text-lg font-bold first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="m-0 mt-3 !text-base font-semibold first:mt-0">{children}</h3>
),
h4: ({ children }) => (
<h4 className="m-0 mt-3 !text-sm font-semibold first:mt-0">{children}</h4>
),
h5: ({ children }) => (
<h5 className="m-0 mt-2 !text-sm font-medium first:mt-0">{children}</h5>
),
h6: ({ children }) => (
<h6 className="m-0 mt-2 !text-xs font-medium uppercase tracking-wide first:mt-0">
{children}
</h6>
),
p: MarkdownParagraph,
ul: MarkdownUnorderedList,
ol: MarkdownOrderedList,
li: MarkdownListItem,
blockquote: ({ children }) => (
<blockquote className="m-0 mt-2 border-l-2 border-(--platform-surface-border) pl-3 text-(--platform-text-soft) first:mt-0">
{children}
</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">
{children}
</pre>
),
code: ({ className, children, node: _node, ...props }) => {
const isBlock =
Boolean(className?.includes('language-')) ||
String(children).includes('\n');
return isBlock ? (
<code {...props} className="font-mono whitespace-pre">
{children}
</code>
) : (
<code
{...props}
className="rounded bg-black/6 px-1 py-0.5 font-mono text-[0.9em]"
>
{children}
</code>
);
},
table: ({ children }) => (
<div className="mt-2 max-w-full overflow-x-auto first:mt-0">
<table className="min-w-full border-collapse text-left text-sm">
{children}
</table>
</div>
),
th: ({ children }) => (
<th className="border border-(--platform-surface-border) px-2 py-1 font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-(--platform-surface-border) px-2 py-1 align-top">
{children}
</td>
),
hr: () => (
<hr className="mt-2 border-0 border-t border-(--platform-surface-border)" />
),
};
const streamingMarkdownComponents: Components = {
...markdownComponents,
p: StreamingMarkdownParagraph,
};
export function ChatMarkdownMessage({
text,
role,
streaming = false,
}: ChatMarkdownMessageProps) {
if (role === 'user') {
return <span className="whitespace-pre-wrap break-words">{text}</span>;
}
return (
<MarkdownErrorBoundary fallbackText={text}>
<ReactMarkdown
skipHtml
remarkPlugins={[remarkGfm]}
components={
streaming ? streamingMarkdownComponents : markdownComponents
}
>
{text}
</ReactMarkdown>
</MarkdownErrorBoundary>
);
}
@@ -2140,6 +2140,14 @@ export function projectSupervisorVisibleConversationText(
);
}
export function projectSupervisorChatMessageText(
message: Pick<ChatMessage, 'text' | 'role'>,
) {
return message.role === 'assistant'
? projectSupervisorVisibleConversationText(message.text, message.role)
: message.text;
}
export function projectRuntimeVisibleToolSummary(summary: string) {
return summary
.split('·')
@@ -272,7 +272,13 @@ export function summarizeProjectFileContent(result: LocalProjectFileResult) {
} 字符`
: result.content;
return `文件:${result.path}\n${content || '空文件'}`;
const visibleContent = content || '空文件';
let longestBacktickRun = 0;
for (const match of visibleContent.matchAll(/`+/gu)) {
longestBacktickRun = Math.max(longestBacktickRun, match[0].length);
}
const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1));
return `文件:${result.path}\n\n${fence}text\n${visibleContent}\n${fence}`;
}
export function inferProjectFileAssetDraft(
@@ -18,11 +18,12 @@ import type {
PlanGddDecisionAction,
PlanGddStateViewV1,
} from '../../app/types';
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
import {
projectProfessionalAgentLabel,
projectRuntimeVisibleError,
projectSupervisorChatMessageText,
ProjectSupervisorRuntimePanel,
projectSupervisorVisibleConversationText,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
@@ -178,15 +179,15 @@ export function ProjectSupervisorView({
</button>
) : null}
{visibleMessages.map((message, index) => (
<p
<div
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
{projectSupervisorVisibleConversationText(
message.text,
message.role,
)}
</p>
<ChatMarkdownMessage
role={message.role}
text={projectSupervisorChatMessageText(message)}
/>
</div>
))}
{directCodex &&
(runtimePanelProps.controlBusy || Boolean(directProcessDetail)) ? (
@@ -206,6 +207,15 @@ export function ProjectSupervisorView({
: '陶泥儿正在处理'}
</strong>
</header>
{transientReply ? (
<div aria-label="陶泥儿实时回复">
<ChatMarkdownMessage
role="assistant"
text={transientReply}
streaming
/>
</div>
) : null}
{directProcessDetail ? (
<div className="project-supervisor-process-detail">
<p
@@ -237,8 +247,8 @@ export function ProjectSupervisorView({
) : null}
</section>
) : null}
{transientReply ? (
<p
{!directCodex && transientReply ? (
<div
className="message message--assistant"
aria-label={
directCodex ? '陶泥儿实时回复' : '项目总控 Agent 实时回复'
@@ -246,8 +256,12 @@ export function ProjectSupervisorView({
aria-live="polite"
data-runtime-owned="true"
>
{transientReply}
</p>
<ChatMarkdownMessage
role="assistant"
text={transientReply}
streaming
/>
</div>
) : null}
</div>
{directCodex ? null : isPlanningLaneRuntime(
@@ -26,12 +26,13 @@ import type {
PendingCommand,
PendingUiConfirmation,
} from '../../app/types';
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
import type { ProjectAgentResultSummary } from '../../view/project-development';
import {
formatAgentRecentRuntimeTask,
formatAgentRuntimeTaskQueue,
projectSupervisorChatMessageText,
ProjectSupervisorRuntimePanel,
projectSupervisorVisibleConversationText,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import {
@@ -804,12 +805,12 @@ export function ProjectWorkspaceChatPane({
) : null}
{visibleMessages.map((message, index) => (
<Fragment key={index}>
<p className={`message message--${message.role}`}>
{projectSupervisorVisibleConversationText(
message.text,
message.role,
)}
</p>
<div className={`message message--${message.role}`}>
<ChatMarkdownMessage
role={message.role}
text={projectSupervisorChatMessageText(message)}
/>
</div>
{message.draftCommand ? (
<button
type="button"
@@ -822,14 +823,18 @@ export function ProjectWorkspaceChatPane({
</Fragment>
))}
{projectSupervisorTransientReply ? (
<p
<div
className="message message--assistant"
aria-label="项目总控 Agent 实时回复"
aria-live="polite"
data-runtime-owned="true"
>
{projectSupervisorTransientReply}
</p>
<ChatMarkdownMessage
role="assistant"
text={projectSupervisorTransientReply}
streaming
/>
</div>
) : null}
</div>
<ProjectSupervisorRuntimePanel
@@ -5,9 +5,10 @@ import type {
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
FormEventHandler,
RefObject,
SetStateAction,
UIEvent,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
UIEventHandler,
} from 'react';
import { useLayoutEffect } from 'react';
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
import { useLayoutEffect, useRef } from 'react';
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
import type {
AgentRuntimeState,
@@ -15,12 +16,13 @@ import type {
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
PendingCommand,
PendingUiConfirmation,
} from '../../app/types';
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
import {
isAgentRuntimeTerminalState,
projectRuntimeVisibleError,
projectSupervisorChatMessageText,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
projectSupervisorChatRuntimeStatus,
ProjectSupervisorRuntimeControls,
projectSupervisorVisibleConversationText,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
@@ -33,6 +35,8 @@ type RuntimeControlProps = ComponentProps<
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
typeof ProjectSupervisorRuntimeControls
>;
const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
type SupervisorChatOnlyViewProps = {
chatAgentBusy: boolean;
chatInput: string;
@@ -95,6 +99,7 @@ export function SupervisorChatOnlyView({
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
visibleMessages,
workspaceStatus,
}: SupervisorChatOnlyViewProps) {
const shouldFollowLatestRef = useRef(true);
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
const running = Boolean(
chatAgentBusy || (runtime && !isAgentRuntimeTerminalState(runtime)),
);
@@ -108,9 +113,33 @@ export function SupervisorChatOnlyView({
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
? projectSupervisorChatRuntimeStatus(runtime)
: projectWorkspaceStatusForDisplay(workspaceStatus);
useLayoutEffect(() => {
if (running && messagesRef.current)
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
}, [messagesRef, running, transientReply, runtime?.updatedAt]);
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
if (!running) {
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
return;
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
const messageList = messagesRef.current;
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
if (!messageList) {
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
return;
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
if (shouldFollowLatestRef.current) {
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
messageList.scrollTop = messageList.scrollHeight;
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
}, [
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
messagesRef,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
running,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
runtime?.updatedAt,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
transientReply,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
visibleMessages.length,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
]);
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
function handleScroll(event: UIEvent<HTMLDivElement>) {
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
const messageList = event.currentTarget;
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
const distanceFromBottom =
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
messageList.scrollHeight -
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
messageList.scrollTop -
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
messageList.clientHeight;
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
shouldFollowLatestRef.current =
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
distanceFromBottom <= CHAT_SCROLL_BOTTOM_THRESHOLD;
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
onScroll(event);
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
const conversationLabel = directCodex
? '陶泥儿项目对话'
: '项目总控 Agent 纯聊天';
@@ -143,7 +172,7 @@ export function SupervisorChatOnlyView({
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
ref={messagesRef}
className="message-list supervisor-chat-only-message-list"
aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'}
onScroll={onScroll}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
onScroll={handleScroll}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
>
{hiddenConversationCount > 0 ? (
<button
@@ -153,18 +182,18 @@ export function SupervisorChatOnlyView({
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
>{`显示更早 · 还有 ${hiddenConversationCount} 条对话`}</button>
) : null}
{visibleMessages.map((message, index) => (
<p
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
<div
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
{projectSupervisorVisibleConversationText(
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
message.text,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
message.role,
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
)}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
</p>
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
<ChatMarkdownMessage
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
role={message.role}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
text={projectSupervisorChatMessageText(message)}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
/>
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
</div>
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
))}
{transientReply ? (
<p
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
<div
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
className="message message--assistant supervisor-chat-only-stream"
aria-label={
directCodex ? '陶泥儿实时回复' : '项目总控 Agent 实时回复'
@@ -172,8 +201,12 @@ export function SupervisorChatOnlyView({
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
aria-live="polite"
data-runtime-owned="true"
>
{transientReply}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
</p>
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
<ChatMarkdownMessage
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
role="assistant"
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
text={transientReply}
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
streaming
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
/>
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
</div>
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
) : null}
{running && !transientReply ? (
<div
k88936 marked this conversation as resolved
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
Review

这里移除了原有的 useLayoutEffect 自动滚动逻辑,但 standalone supervisorChatOnly 路径仍然把 transientReply 持续追加到同一个消息列表(App.tsx 通过 messagesRef={supervisorChatMessagesRef} 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。

这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
@@ -0,0 +1,230 @@
// @vitest-environment jsdom
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
ChatMarkdownMessage,
MarkdownErrorBoundary,
} from '../src/components/ChatMarkdownMessage';
function FailingChild({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) {
throw new Error('transient render failure');
}
return <span>markdown recovered</span>;
}
describe('ChatMarkdownMessage', () => {
it('渲染 assistant 的 GFM 内容与代码块', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'## 标题\n\n- 一项\n- 二项\n\n```ts\nconst answer = 42;\n```'}
/>,
);
expect(container.querySelector('h2')?.textContent).toBe('标题');
expect(container.querySelectorAll('li')).toHaveLength(2);
expect(container.querySelector('pre code')?.textContent).toContain(
'const answer = 42;',
);
});
it('为嵌套无序列表保留逐层缩进', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={
'- 无序号 A\n- 无序号 B\n - 缩进一层的子项\n - 缩进两层的子项\n - 回到一层子项\n- 无序号 C'
}
/>,
);
const lists = container.querySelectorAll('ul');
expect(lists).toHaveLength(3);
expect(lists[0]?.className).toContain('pl-0');
expect(lists[1]?.className).toContain('pl-4');
expect(lists[2]?.className).toContain('pl-4');
});
it('为四到六级标题提供递进的字号与字重', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'#### H4 四级标题\n\n##### H5 五级标题\n\n###### H6 六级标题'}
/>,
);
expect(container.querySelector('h4')?.className).toContain('text-sm');
expect(container.querySelector('h4')?.className).toContain('font-semibold');
expect(container.querySelector('h5')?.className).toContain('font-medium');
expect(container.querySelector('h6')?.className).toContain('text-xs');
expect(container.querySelector('h6')?.className).toContain('tracking-wide');
});
it('有序列表不添加无序列表短横线,列表项段落与文本同行', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'1. 第一件事\n\n2. 第二件事\n\n 1. 子步骤 2.1'}
/>,
);
const items = container.querySelectorAll('ol > li');
expect(items[0]?.textContent?.trim()).toBe('第一件事');
expect(items[1]?.textContent?.trim()).toContain('第二件事');
expect(items[0]?.querySelector('p')?.className ?? '').toContain('inline');
expect(items[0]?.className).toContain('whitespace-normal');
expect(items[0]?.textContent).not.toContain('-');
});
it('保留 Markdown 有序列表的起始编号', () => {
const { container } = render(
<ChatMarkdownMessage role="assistant" text={'3. third\n4. fourth'} />,
);
expect(container.querySelector('ol')?.getAttribute('start')).toBe('3');
expect(container.querySelectorAll('ol > li')).toHaveLength(2);
});
it('为列表项后续段落保留段落间距', () => {
const { container } = render(
<ChatMarkdownMessage role="assistant" text={'- first\n\n second'} />,
);
const paragraphs = container.querySelectorAll('ul > li p');
expect(paragraphs).toHaveLength(2);
expect(paragraphs[0]?.className).toContain('inline');
expect(paragraphs[1]?.className).toContain('mt-2');
expect(paragraphs[1]?.className).not.toContain('inline');
});
it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'```ts\nconst answer = 42;\n```'}
/>,
);
expect(
container.querySelector('pre code')?.getAttribute('node'),
).toBeNull();
});
it('无语言标记的多行围栏代码仍使用代码块样式', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'```\nconst answer = 42;\nreturn answer;\n```'}
/>,
);
const code = container.querySelector('pre code');
expect(code?.className).toContain('whitespace-pre');
expect(code?.className).not.toContain('rounded');
});
it('保留行内代码中的 HTML 字面量', () => {
const { container } = render(
<ChatMarkdownMessage role="assistant" text={'`<span>`'} />,
);
expect(container.querySelector('code')?.textContent).toBe('<span>');
});
it('保留围栏代码中的 HTML 字面量而不双重转义', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'```html\n<div class="card"></div>\n```'}
/>,
);
expect(container.querySelector('pre code')?.textContent).toContain(
'<div class="card"></div>',
);
expect(container.querySelector('pre code')?.textContent).not.toContain(
'&lt;div',
);
});
it('累计文本变化后可从 Markdown 错误回退中恢复', () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
try {
const { container, rerender } = render(
<MarkdownErrorBoundary fallbackText="partial">
<FailingChild shouldThrow />
</MarkdownErrorBoundary>,
);
expect(container.textContent).toBe('partial');
rerender(
<MarkdownErrorBoundary fallbackText="complete">
<FailingChild shouldThrow={false} />
</MarkdownErrorBoundary>,
);
expect(container.textContent).toBe('markdown recovered');
} finally {
consoleError.mockRestore();
}
});
it('保留流式 assistant 文本并标记 streaming 状态', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
streaming
text={'正在生成 **结果'}
/>,
);
expect(container.textContent).toContain('正在生成');
expect(container.textContent).toContain('结果');
});
it('用户消息保持纯文本,不解析 Markdown', () => {
const { container } = render(
<ChatMarkdownMessage role="user" text={'**不要解析**\n`/read game`'} />,
);
expect(container.querySelector('strong')).toBeNull();
expect(container.querySelector('code')).toBeNull();
expect(container.textContent).toContain('**不要解析**');
expect(container.textContent).toContain('`/read game`');
});
it('不输出原始 HTML、可点击链接或图片节点', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={
'<script>alert(1)</script>\n\n[外链](https://example.com) ![示意图](x.png)'
}
/>,
);
expect(container.querySelector('script')).toBeNull();
expect(container.querySelector('a')).toBeNull();
expect(container.querySelector('img')).toBeNull();
expect(container.textContent).toContain('外链');
expect(container.textContent).toContain('图片:示意图');
});
it('支持表格并允许窄视口横向滚动', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'| 名称 | 状态 |\n| --- | --- |\n| demo | ready |'}
/>,
);
expect(container.querySelectorAll('table th')).toHaveLength(2);
expect(container.querySelector('div.overflow-x-auto')).toBeTruthy();
});
});
@@ -923,7 +923,9 @@ export function registerProjectAssetTests() {
submitChat('/read game/index.html');
expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull();
expect(screen.getByText(/<canvas id="game"><\/canvas>/)).not.toBeNull();
const fileContent = screen.getByText('<canvas id="game"></canvas>');
expect(fileContent.tagName).toBe('CODE');
expect(fileContent.closest('pre')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
projectPath: '/tmp/authorized-game',
relativePath: 'game/index.html',
@@ -992,7 +994,9 @@ export function registerProjectAssetTests() {
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull();
expect(screen.getByText(/<canvas id="game"><\/canvas>/)).not.toBeNull();
const fileContent = screen.getByText('<canvas id="game"></canvas>');
expect(fileContent.tagName).toBe('CODE');
expect(fileContent.closest('pre')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
projectPath: '/tmp/authorized-game',
relativePath: 'game/index.html',
@@ -3805,7 +3805,13 @@ export function registerProjectCommandTests() {
submitChat('/files');
expect(await screen.findByText(/本地项目文件:/)).not.toBeNull();
expect(screen.getByText(/- game\//)).not.toBeNull();
expect(
screen.getByText(
(_, element) =>
element?.tagName === 'LI' &&
element.textContent?.trim() === '- game/',
),
).not.toBeNull();
expect(screen.getByText(/- game\/index\.html/)).not.toBeNull();
expect(screen.getByText(/- assets\/uploads\/hero\.png/)).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('list_local_project_files', {
@@ -1,5 +1,6 @@
import { expect, fireEvent, screen, submitChat, within } from '../../harness';
import type { PreviewShortcutInvoke } from './invoke-mock';
import { messageBubble } from './message-bubble';
export async function assertPlanningAndStatusShortcutFlow(
invoke: PreviewShortcutInvoke,
@@ -70,7 +71,9 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/blockers');
const blockerMessages = await screen.findAllByText(/当前阻塞项:/);
const blockerMessage = blockerMessages[blockerMessages.length - 1];
const blockerMessage = messageBubble(
blockerMessages[blockerMessages.length - 1],
);
expect(blockerMessage.textContent).toContain('项目:未命名游戏原型');
expect(blockerMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
@@ -170,7 +173,7 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/ready');
const readyMessages = await screen.findAllByText(/试玩就绪度:/);
const readyMessage = readyMessages[readyMessages.length - 1];
const readyMessage = messageBubble(readyMessages[readyMessages.length - 1]);
expect(readyMessage.textContent).toContain('项目:未命名游戏原型');
expect(readyMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
@@ -270,7 +273,9 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/evidence');
const evidenceMessages = await screen.findAllByText(/验证证据台账:/);
const evidenceMessage = evidenceMessages[evidenceMessages.length - 1];
const evidenceMessage = messageBubble(
evidenceMessages[evidenceMessages.length - 1],
);
expect(evidenceMessage.textContent).toContain('项目:未命名游戏原型');
expect(evidenceMessage.textContent).toContain(
'Run trace:已有 run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
@@ -377,7 +382,9 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/deps');
const dependencyMessages = await screen.findAllByText(/任务依赖链:/);
const dependencyMessage = dependencyMessages[dependencyMessages.length - 1];
const dependencyMessage = messageBubble(
dependencyMessages[dependencyMessages.length - 1],
);
expect(dependencyMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
@@ -479,7 +486,9 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/revise');
const revisionMessages = await screen.findAllByText(/改版草稿:/);
const revisionMessage = revisionMessages[revisionMessages.length - 1];
const revisionMessage = messageBubble(
revisionMessages[revisionMessages.length - 1],
);
expect(revisionMessage.textContent).toContain('项目:未命名游戏原型');
expect(revisionMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
@@ -604,7 +613,9 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/privacy');
const privacyMessages = await screen.findAllByText(/隐私与导出边界:/);
const privacyMessage = privacyMessages[privacyMessages.length - 1];
const privacyMessage = messageBubble(
privacyMessages[privacyMessages.length - 1],
);
expect(privacyMessage.textContent).toContain('项目:未命名游戏原型');
expect(privacyMessage.textContent).toContain(
'本地目录:/tmp/authorized-game',
@@ -616,10 +627,10 @@ export async function assertPlanningAndStatusShortcutFlow(
'本地预览:未启动;仅限 127.0.0.1 本机访问',
);
expect(privacyMessage.textContent).toContain(
'试玩包:尚未导出;只应包含 game/**、assets/** 和 exports/README.md',
'试玩包:尚未导出;只应包含 game/、assets/ 和 exports/README.md',
);
expect(privacyMessage.textContent).toContain(
'内部文件:.agent/**、memory/**、日志、trace、配置和密钥不得进入试玩包',
'内部文件:.agent/、memory/、日志、trace、配置和密钥不得进入试玩包',
);
expect(privacyMessage.textContent).toContain(
'素材来源:2 个;上传 1 / 画板 1',
@@ -697,7 +708,9 @@ export async function assertPlanningAndStatusShortcutFlow(
submitChat('/criteria');
expect(await screen.findByText(/当前验收标准:/)).not.toBeNull();
const criteriaMessages = screen.getAllByText(/当前验收标准:/);
const criteriaMessage = criteriaMessages[criteriaMessages.length - 1];
const criteriaMessage = messageBubble(
criteriaMessages[criteriaMessages.length - 1],
);
expect(criteriaMessage.textContent).toContain(
'ready:美术组 / Asset 生成首版美术素材(art-asset-plan)',
);
@@ -740,7 +753,7 @@ export async function assertPlanningAndStatusShortcutFlow(
submitChat('/groups');
expect(await screen.findByText(/专业组进度:/)).not.toBeNull();
const groupMessages = screen.getAllByText(/专业组进度:/);
const groupMessage = groupMessages[groupMessages.length - 1];
const groupMessage = messageBubble(groupMessages[groupMessages.length - 1]);
expect(groupMessage.textContent).toContain(
'美术组:完成 0/3 · active 0 · carry 0 · ready 1',
);
@@ -793,7 +806,9 @@ export async function assertPlanningAndStatusShortcutFlow(
submitChat('/balance');
expect(await screen.findByText(/数值状态:/)).not.toBeNull();
const balanceMessages = screen.getAllByText(/数值状态:/);
const balanceMessage = balanceMessages[balanceMessages.length - 1];
const balanceMessage = messageBubble(
balanceMessages[balanceMessages.length - 1],
);
expect(balanceMessage.textContent).toContain('项目:未命名游戏原型');
expect(balanceMessage.textContent).toContain(
'balance-directorDirector 确定数值口径 · 待处理',
@@ -874,7 +889,9 @@ export async function assertPlanningAndStatusShortcutFlow(
submitChat('/budget');
expect(await screen.findByText(/运行预算:/)).not.toBeNull();
const budgetMessages = screen.getAllByText(/运行预算:/);
const budgetMessage = budgetMessages[budgetMessages.length - 1];
const budgetMessage = messageBubble(
budgetMessages[budgetMessages.length - 1],
);
expect(budgetMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
@@ -923,7 +940,7 @@ export async function assertPlanningAndStatusShortcutFlow(
submitChat('/qa');
expect(await screen.findByText(/质量检查:/)).not.toBeNull();
const qaMessages = screen.getAllByText(/质量检查:/);
const qaMessage = qaMessages[qaMessages.length - 1];
const qaMessage = messageBubble(qaMessages[qaMessages.length - 1]);
expect(qaMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
@@ -988,7 +1005,9 @@ export async function assertPlanningAndStatusShortcutFlow(
submitChat('/changes');
expect(await screen.findByText(/最近变更:/)).not.toBeNull();
const changeMessages = screen.getAllByText(/最近变更:/);
const changeMessage = changeMessages[changeMessages.length - 1];
const changeMessage = messageBubble(
changeMessages[changeMessages.length - 1],
);
expect(changeMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
@@ -1064,7 +1083,9 @@ export async function assertPlanningAndStatusShortcutFlow(
submitChat('/review');
expect(await screen.findByText(/评审状态:/)).not.toBeNull();
const reviewMessages = screen.getAllByText(/评审状态:/);
const reviewMessage = reviewMessages[reviewMessages.length - 1];
const reviewMessage = messageBubble(
reviewMessages[reviewMessages.length - 1],
);
expect(reviewMessage.textContent).toContain('Evaluator:通过');
expect(reviewMessage.textContent).toContain('返工焦点:暂无');
expect(reviewMessage.textContent).toContain(
@@ -1150,7 +1171,9 @@ export async function assertPlanningAndStatusShortcutFlow(
submitChat('/timeline');
expect(await screen.findByText(/项目时间线:/)).not.toBeNull();
const timelineMessages = screen.getAllByText(/项目时间线:/);
const timelineMessage = timelineMessages[timelineMessages.length - 1];
const timelineMessage = messageBubble(
timelineMessages[timelineMessages.length - 1],
);
expect(timelineMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done',
);
@@ -1209,7 +1232,9 @@ export async function assertPlanningAndStatusShortcutFlow(
submitChat('/handoff');
expect(await screen.findByText(/项目交接:/)).not.toBeNull();
const handoffMessages = screen.getAllByText(/项目交接:/);
const handoffMessage = handoffMessages[handoffMessages.length - 1];
const handoffMessage = messageBubble(
handoffMessages[handoffMessages.length - 1],
);
expect(handoffMessage.textContent).toContain(
'- Runrun-main-shortcut-trace',
);
@@ -1335,14 +1360,14 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/todo');
const todoMessages = await screen.findAllByText(/下一轮小步:/);
const todoMessage = todoMessages[todoMessages.length - 1];
const todoMessage = messageBubble(todoMessages[todoMessages.length - 1]);
expect(todoMessage.textContent).toContain('项目:未命名游戏原型');
expect(todoMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
expect(todoMessage.textContent).toContain('编排下一步:preview');
expect(todoMessage.textContent).toContain(
'1. ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记',
'ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记',
);
expect(todoMessage.textContent).toContain(
'边界:只整理下一步;不读取任务文件;不启动 run;不修改项目',
@@ -1428,7 +1453,7 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/plan');
const planMessages = await screen.findAllByText(/下一轮分工计划:/);
const planMessage = planMessages[planMessages.length - 1];
const planMessage = messageBubble(planMessages[planMessages.length - 1]);
expect(planMessage.textContent).toContain('项目:未命名游戏原型');
expect(planMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
@@ -1533,7 +1558,7 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/guide');
const guideMessages = await screen.findAllByText(/使用导引:/);
const guideMessage = guideMessages[guideMessages.length - 1];
const guideMessage = messageBubble(guideMessages[guideMessages.length - 1]);
expect(guideMessage.textContent).toContain('项目:未命名游戏原型');
expect(guideMessage.textContent).toContain('当前阶段:可导出');
expect(guideMessage.textContent).toContain(
@@ -1627,7 +1652,9 @@ export async function assertPlanningAndStatusShortcutFlow(
};
submitChat('/progress');
const progressMessages = await screen.findAllByText(/项目进度:/);
const progressMessage = progressMessages[progressMessages.length - 1];
const progressMessage = messageBubble(
progressMessages[progressMessages.length - 1],
);
expect(progressMessage.textContent).toContain('项目:未命名游戏原型');
expect(progressMessage.textContent).toContain('当前阶段:已生成');
expect(progressMessage.textContent).toMatch(
@@ -1,5 +1,6 @@
import { expect, fireEvent, screen, submitChat, within } from '../../harness';
import type { PreviewShortcutInvoke } from './invoke-mock';
import { messageBubble } from './message-bubble';
export async function assertPlaytestAndReleaseShortcutFlow(
invoke: PreviewShortcutInvoke,
@@ -170,7 +171,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/listing');
const listingMessages = await screen.findAllByText(/作品页草稿:/);
const listingMessage = listingMessages[listingMessages.length - 1];
const listingMessage = messageBubble(
listingMessages[listingMessages.length - 1],
);
expect(listingMessage.textContent).toContain('标题:未命名游戏原型');
expect(listingMessage.textContent).toContain(
'一句话卖点:做一个厨房弹幕游戏',
@@ -262,7 +265,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
).length;
submitChat('/playtest');
const playtestMessages = await screen.findAllByText(/试玩状态:/);
const playtestMessage = playtestMessages[playtestMessages.length - 1];
const playtestMessage = messageBubble(
playtestMessages[playtestMessages.length - 1],
);
expect(playtestMessage.textContent).toContain(
'原型:最近 run 已通过 run-main-shortcut-trace',
);
@@ -329,26 +334,26 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/test-plan');
const testPlanMessages = await screen.findAllByText(/手动测试计划:/);
const testPlanMessage = testPlanMessages[testPlanMessages.length - 1];
const testPlanMessage = messageBubble(
testPlanMessages[testPlanMessages.length - 1],
);
expect(testPlanMessage.textContent).toContain('项目:未命名游戏原型');
expect(testPlanMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(testPlanMessage.textContent).toContain(
'当前证据:最近 run 已通过 run-main-shortcut-trace;预览 未启动;入口 未见 trace 产物',
);
expect(testPlanMessage.textContent).toContain(
'1. 启动预览:/run 后确认首屏不空白',
'启动预览:/run 后确认首屏不空白',
);
expect(testPlanMessage.textContent).toContain(
'2. 30 秒理解:目标、操作、得分/失败和重开可见',
'30 秒理解:目标、操作、得分/失败和重开可见',
);
expect(testPlanMessage.textContent).toContain(
'3. 输入验证:键盘/点击/触屏至少一种可完成核心动作',
'输入验证:键盘/点击/触屏至少一种可完成核心动作',
);
expect(testPlanMessage.textContent).toContain('结局验证:胜利或失败后可重开');
expect(testPlanMessage.textContent).toContain(
'4. 结局验证:胜利或失败后可重开',
);
expect(testPlanMessage.textContent).toContain(
'5. 回归检查:/mobile/accessibility/performance/audio',
'回归检查:/mobile/accessibility/performance/audio',
);
expect(testPlanMessage.textContent).toContain(
'preview-readiness:程序组 / Preview 执行静态自检 · 待处理',
@@ -432,7 +437,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/audience');
const audienceMessages = await screen.findAllByText(/首批试玩对象:/);
const audienceMessage = audienceMessages[audienceMessages.length - 1];
const audienceMessage = messageBubble(
audienceMessages[audienceMessages.length - 1],
);
expect(audienceMessage.textContent).toContain('项目:未命名游戏原型');
expect(audienceMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(audienceMessage.textContent).toContain(
@@ -545,7 +552,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/invite');
const inviteMessages = await screen.findAllByText(/试玩邀请:/);
const inviteMessage = inviteMessages[inviteMessages.length - 1];
const inviteMessage = messageBubble(
inviteMessages[inviteMessages.length - 1],
);
expect(inviteMessage.textContent).toContain('项目:未命名游戏原型');
expect(inviteMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(inviteMessage.textContent).toContain(
@@ -723,7 +732,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/survey');
const surveyMessages = await screen.findAllByText(/试玩问卷:/);
const surveyMessage = surveyMessages[surveyMessages.length - 1];
const surveyMessage = messageBubble(
surveyMessages[surveyMessages.length - 1],
);
expect(surveyMessage.textContent).toContain('项目:未命名游戏原型');
expect(surveyMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(surveyMessage.textContent).toContain(
@@ -830,7 +841,7 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/cover');
const coverMessages = await screen.findAllByText(/封面与缩略图:/);
const coverMessage = coverMessages[coverMessages.length - 1];
const coverMessage = messageBubble(coverMessages[coverMessages.length - 1]);
expect(coverMessage.textContent).toContain('项目:未命名游戏原型');
expect(coverMessage.textContent).toContain(
'当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动',
@@ -939,7 +950,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/screenshots');
const screenshotMessages = await screen.findAllByText(/宣传截图:/);
const screenshotMessage = screenshotMessages[screenshotMessages.length - 1];
const screenshotMessage = messageBubble(
screenshotMessages[screenshotMessages.length - 1],
);
expect(screenshotMessage.textContent).toContain('项目:未命名游戏原型');
expect(screenshotMessage.textContent).toContain(
'当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动',
@@ -1048,7 +1061,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/trailer');
const trailerMessages = await screen.findAllByText(/试玩短视频:/);
const trailerMessage = trailerMessages[trailerMessages.length - 1];
const trailerMessage = messageBubble(
trailerMessages[trailerMessages.length - 1],
);
expect(trailerMessage.textContent).toContain('项目:未命名游戏原型');
expect(trailerMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(trailerMessage.textContent).toContain(
@@ -1158,7 +1173,7 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/faq');
const faqMessages = await screen.findAllByText(/试玩 FAQ/);
const faqMessage = faqMessages[faqMessages.length - 1];
const faqMessage = messageBubble(faqMessages[faqMessages.length - 1]);
expect(faqMessage.textContent).toContain('项目:未命名游戏原型');
expect(faqMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(faqMessage.textContent).toContain(
@@ -1263,7 +1278,7 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/post');
const postMessages = await screen.findAllByText(/社区发布文案:/);
const postMessage = postMessages[postMessages.length - 1];
const postMessage = messageBubble(postMessages[postMessages.length - 1]);
expect(postMessage.textContent).toContain('项目:未命名游戏原型');
expect(postMessage.textContent).toContain('一句话:做一个厨房弹幕游戏');
expect(postMessage.textContent).toContain(
@@ -1374,7 +1389,7 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/store');
const storeMessages = await screen.findAllByText(/上架资料:/);
const storeMessage = storeMessages[storeMessages.length - 1];
const storeMessage = messageBubble(storeMessages[storeMessages.length - 1]);
expect(storeMessage.textContent).toContain('项目:未命名游戏原型');
expect(storeMessage.textContent).toContain('一句话:做一个厨房弹幕游戏');
expect(storeMessage.textContent).toContain(
@@ -1484,7 +1499,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/media-kit');
const mediaKitMessages = await screen.findAllByText(/媒体资料包:/);
const mediaKitMessage = mediaKitMessages[mediaKitMessages.length - 1];
const mediaKitMessage = messageBubble(
mediaKitMessages[mediaKitMessages.length - 1],
);
expect(mediaKitMessage.textContent).toContain('项目:未命名游戏原型');
expect(mediaKitMessage.textContent).toContain('一句话:做一个厨房弹幕游戏');
expect(mediaKitMessage.textContent).toContain(
@@ -1591,8 +1608,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/release-notes');
const releaseNotesMessages = await screen.findAllByText(/试玩更新说明:/);
const releaseNotesMessage =
releaseNotesMessages[releaseNotesMessages.length - 1];
const releaseNotesMessage = messageBubble(
releaseNotesMessages[releaseNotesMessages.length - 1],
);
expect(releaseNotesMessage.textContent).toContain('项目:未命名游戏原型');
expect(releaseNotesMessage.textContent).toContain(
'一句话:做一个厨房弹幕游戏',
@@ -1709,7 +1727,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
};
submitChat('/known-issues');
const knownIssueMessages = await screen.findAllByText(/已知问题清单:/);
const knownIssueMessage = knownIssueMessages[knownIssueMessages.length - 1];
const knownIssueMessage = messageBubble(
knownIssueMessages[knownIssueMessages.length - 1],
);
expect(knownIssueMessage.textContent).toContain('项目:未命名游戏原型');
expect(knownIssueMessage.textContent).toContain(
'当前状态:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed;预览 未启动',
@@ -1,5 +1,6 @@
import { expect, fireEvent, screen, submitChat, within } from '../../harness';
import type { PreviewShortcutInvoke } from './invoke-mock';
import { messageBubble } from './message-bubble';
export async function assertProjectAndDesignShortcutFlow(
invoke: PreviewShortcutInvoke,
@@ -43,11 +44,12 @@ export async function assertProjectAndDesignShortcutFlow(
),
).toBe(true);
submitChat('/agent-conversations');
expect(
await screen.findByText(
/Agent 对话读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read \.agent\/conversations\/agents\/art-asset-plan\.jsonl/,
),
).not.toBeNull();
const agentConversationMessage =
await screen.findByText('Agent 对话读取命令:');
const agentConversationBubble = messageBubble(agentConversationMessage);
expect(agentConversationBubble.textContent).toContain(
'美术组 / Asset · 生成首版美术素材:/read .agent/conversations/agents/art-asset-plan.jsonl',
);
fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向对话' }));
expect(screen.getByLabelText('创作想法')).toHaveProperty(
'value',
@@ -60,11 +62,12 @@ export async function assertProjectAndDesignShortcutFlow(
}),
);
submitChat('/agent-memories');
expect(
await screen.findByText(
/Agent 私有记忆读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read memory\/agents\/art\/asset\.md/,
),
).not.toBeNull();
const agentMemoryMessage =
await screen.findByText('Agent 私有记忆读取命令:');
const agentMemoryBubble = messageBubble(agentMemoryMessage);
expect(agentMemoryBubble.textContent).toContain(
'美术组 / Asset · 生成首版美术素材:/read memory/agents/art/asset.md',
);
fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向记忆' }));
expect(screen.getByLabelText('创作想法')).toHaveProperty(
'value',
@@ -126,7 +129,7 @@ export async function assertProjectAndDesignShortcutFlow(
submitChat('/goal');
expect(await screen.findByText(/创作目标:/)).not.toBeNull();
const goalMessages = screen.getAllByText(/创作目标:/);
const goalMessage = goalMessages[goalMessages.length - 1];
const goalMessage = messageBubble(goalMessages[goalMessages.length - 1]);
expect(goalMessage.textContent).toContain('Manifest:做一个厨房弹幕游戏');
expect(goalMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
@@ -184,7 +187,7 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/spec');
const specMessages = await screen.findAllByText(/创作规格包:/);
const specMessage = specMessages[specMessages.length - 1];
const specMessage = messageBubble(specMessages[specMessages.length - 1]);
expect(specMessage.textContent).toContain('项目:未命名游戏原型');
expect(specMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(specMessage.textContent).toContain(
@@ -284,7 +287,7 @@ export async function assertProjectAndDesignShortcutFlow(
submitChat('/mvp');
expect(await screen.findByText(/MVP 范围:/)).not.toBeNull();
const mvpMessages = screen.getAllByText(/MVP 范围:/);
const mvpMessage = mvpMessages[mvpMessages.length - 1];
const mvpMessage = messageBubble(mvpMessages[mvpMessages.length - 1]);
expect(mvpMessage.textContent).toContain('项目:未命名游戏原型');
expect(mvpMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(mvpMessage.textContent).toContain(
@@ -380,7 +383,7 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/pitch');
const pitchMessages = await screen.findAllByText(/试玩定位:/);
const pitchMessage = pitchMessages[pitchMessages.length - 1];
const pitchMessage = messageBubble(pitchMessages[pitchMessages.length - 1]);
expect(pitchMessage.textContent).toContain('项目:未命名游戏原型');
expect(pitchMessage.textContent).toContain('一句话:做一个厨房弹幕游戏');
expect(pitchMessage.textContent).toContain(
@@ -475,7 +478,7 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/demo');
const demoMessages = await screen.findAllByText(/试玩讲解稿:/);
const demoMessage = demoMessages[demoMessages.length - 1];
const demoMessage = messageBubble(demoMessages[demoMessages.length - 1]);
expect(demoMessage.textContent).toContain('项目:未命名游戏原型');
expect(demoMessage.textContent).toContain(
'30 秒开场:这是《未命名游戏原型》,目标是做一个厨房弹幕游戏',
@@ -575,7 +578,9 @@ export async function assertProjectAndDesignShortcutFlow(
submitChat('/rules');
expect(await screen.findByText(/玩法操作:/)).not.toBeNull();
const controlMessages = screen.getAllByText(/玩法操作:/);
const controlMessage = controlMessages[controlMessages.length - 1];
const controlMessage = messageBubble(
controlMessages[controlMessages.length - 1],
);
expect(controlMessage.textContent).toContain('项目:未命名游戏原型');
expect(controlMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(controlMessage.textContent).toContain(
@@ -679,7 +684,9 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/tutorial');
const tutorialMessages = await screen.findAllByText(/新手引导:/);
const tutorialMessage = tutorialMessages[tutorialMessages.length - 1];
const tutorialMessage = messageBubble(
tutorialMessages[tutorialMessages.length - 1],
);
expect(tutorialMessage.textContent).toContain('项目:未命名游戏原型');
expect(tutorialMessage.textContent).toContain('首屏目标:做一个厨房弹幕游戏');
expect(tutorialMessage.textContent).toContain(
@@ -782,7 +789,9 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/mobile');
const mobileMessages = await screen.findAllByText(/移动试玩:/);
const mobileMessage = mobileMessages[mobileMessages.length - 1];
const mobileMessage = messageBubble(
mobileMessages[mobileMessages.length - 1],
);
expect(mobileMessage.textContent).toContain('项目:未命名游戏原型');
expect(mobileMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(mobileMessage.textContent).toContain(
@@ -891,8 +900,9 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/compatibility');
const compatibilityMessages = await screen.findAllByText(/兼容性说明:/);
const compatibilityMessage =
compatibilityMessages[compatibilityMessages.length - 1];
const compatibilityMessage = messageBubble(
compatibilityMessages[compatibilityMessages.length - 1],
);
expect(compatibilityMessage.textContent).toContain('项目:未命名游戏原型');
expect(compatibilityMessage.textContent).toContain(
'目标:做一个厨房弹幕游戏',
@@ -1006,8 +1016,9 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/accessibility');
const accessibilityMessages = await screen.findAllByText(/可读性与无障碍:/);
const accessibilityMessage =
accessibilityMessages[accessibilityMessages.length - 1];
const accessibilityMessage = messageBubble(
accessibilityMessages[accessibilityMessages.length - 1],
);
expect(accessibilityMessage.textContent).toContain('项目:未命名游戏原型');
expect(accessibilityMessage.textContent).toContain(
'目标:做一个厨房弹幕游戏',
@@ -1129,8 +1140,9 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/localization');
const localizationMessages = await screen.findAllByText(/本地化与文案:/);
const localizationMessage =
localizationMessages[localizationMessages.length - 1];
const localizationMessage = messageBubble(
localizationMessages[localizationMessages.length - 1],
);
expect(localizationMessage.textContent).toContain('项目:未命名游戏原型');
expect(localizationMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(localizationMessage.textContent).toContain(
@@ -1259,8 +1271,9 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/performance');
const performanceMessages = await screen.findAllByText(/性能与加载:/);
const performanceMessage =
performanceMessages[performanceMessages.length - 1];
const performanceMessage = messageBubble(
performanceMessages[performanceMessages.length - 1],
);
expect(performanceMessage.textContent).toContain('项目:未命名游戏原型');
expect(performanceMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(performanceMessage.textContent).toContain(
@@ -1376,7 +1389,9 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/polish');
const polishMessages = await screen.findAllByText(/试玩前打磨:/);
const polishMessage = polishMessages[polishMessages.length - 1];
const polishMessage = messageBubble(
polishMessages[polishMessages.length - 1],
);
expect(polishMessage.textContent).toContain('项目:未命名游戏原型');
expect(polishMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(polishMessage.textContent).toContain(
@@ -1488,7 +1503,9 @@ export async function assertProjectAndDesignShortcutFlow(
};
submitChat('/credits');
const creditMessages = await screen.findAllByText(/素材署名:/);
const creditMessage = creditMessages[creditMessages.length - 1];
const creditMessage = messageBubble(
creditMessages[creditMessages.length - 1],
);
expect(creditMessage.textContent).toContain('当前资产:2 个');
expect(creditMessage.textContent).toContain('来源分布:上传 1 / 画板 1');
expect(creditMessage.textContent).toContain(
@@ -7,6 +7,7 @@ import {
within,
} from '../../harness';
import type { PreviewShortcutInvoke } from './invoke-mock';
import { messageBubble } from './message-bubble';
export async function assertProjectToolsAndPreviewFlow(
invoke: PreviewShortcutInvoke,
@@ -36,7 +37,9 @@ export async function assertProjectToolsAndPreviewFlow(
};
submitChat('/feedback');
const feedbackMessages = await screen.findAllByText(/试玩反馈:/);
const feedbackMessage = feedbackMessages[feedbackMessages.length - 1];
const feedbackMessage = messageBubble(
feedbackMessages[feedbackMessages.length - 1],
);
expect(feedbackMessage.textContent).toContain(
'Runrun-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
);
@@ -125,7 +128,9 @@ export async function assertProjectToolsAndPreviewFlow(
};
submitChat('/retention');
const retentionMessages = await screen.findAllByText(/复玩观察:/);
const retentionMessage = retentionMessages[retentionMessages.length - 1];
const retentionMessage = messageBubble(
retentionMessages[retentionMessages.length - 1],
);
expect(retentionMessage.textContent).toContain('项目:未命名游戏原型');
expect(retentionMessage.textContent).toContain('目标:做一个厨房弹幕游戏');
expect(retentionMessage.textContent).toContain(
@@ -233,7 +238,7 @@ export async function assertProjectToolsAndPreviewFlow(
};
submitChat('/share');
const shareMessages = await screen.findAllByText(/试玩交付:/);
const shareMessage = shareMessages[shareMessages.length - 1];
const shareMessage = messageBubble(shareMessages[shareMessages.length - 1]);
expect(shareMessage.textContent).toContain('项目:未命名游戏原型');
expect(shareMessage.textContent).toContain('目录:/tmp/authorized-game');
expect(shareMessage.textContent).toContain(
@@ -438,11 +443,11 @@ export async function assertProjectToolsAndPreviewFlow(
expect(composerInput).toHaveProperty('value', '/read game/index.html');
await waitFor(() => expect(document.activeElement).toBe(composerInput));
submitChat('/run-artifacts');
expect(
await screen.findByText(
/最近 Run 产物读取命令:[\s\S]*exports\/README\.md · 128B · fnv1a64:exports\/read exports\/README\.md/,
),
).not.toBeNull();
const runArtifactsHeading =
await screen.findByText('最近 Run 产物读取命令:');
expect(messageBubble(runArtifactsHeading).textContent).toContain(
'exports/README.md · 128B · fnv1a64:exports/read exports/README.md',
);
fireEvent.click(screen.getByRole('button', { name: '读取首个 Run 产物' }));
expect(composerInput).toHaveProperty('value', '/read exports/README.md');
expect(invoke).not.toHaveBeenCalledWith(
@@ -457,8 +462,9 @@ export async function assertProjectToolsAndPreviewFlow(
submitChat('/passes');
const passArtifactMessages =
await screen.findAllByText(/Agent 轮次产物读取命令:/);
const passArtifactMessage =
passArtifactMessages[passArtifactMessages.length - 1];
const passArtifactMessage = messageBubble(
passArtifactMessages[passArtifactMessages.length - 1],
);
expect(passArtifactMessage.textContent).toContain(
'.agent/passes/pass-1/agenda.md · 96B · fnv1a64:agenda',
);
@@ -564,13 +570,12 @@ export async function assertProjectToolsAndPreviewFlow(
expect(screen.getByText(/下一步:设计实现组 \/ Director/)).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Trace' }));
expect(
await screen.findByText(
(content) =>
content.trimStart().startsWith('Runrun-main-shortcut-trace') &&
content.includes('产物快照:'),
),
).not.toBeNull();
const traceHeading = await screen.findByText(
(content, element) =>
element?.tagName === 'P' &&
content.trimStart().startsWith('Runrun-main-shortcut-trace'),
);
expect(messageBubble(traceHeading).textContent).toContain('产物快照:');
expect(screen.getByText(/产物快照:/)).not.toBeNull();
expect(
screen.getByText(/exports\/README\.md · fnv1a64:exports/),
@@ -0,0 +1,7 @@
export function messageBubble(element: Element): HTMLElement {
const bubble = element.closest<HTMLElement>('.message');
if (!bubble) {
throw new Error('Expected chat message bubble');
}
return bubble;
}
@@ -185,7 +185,7 @@ completed -> starting(nextSlice)
idle -> focused(document|art|audio|version) -> idle
```
- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。
- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。聊天侧 `/read` 回执中的文件正文必须作为代码块渲染为 `<pre><code>`,以便用户审阅源码字面量但不执行其中的 HTML;Markdown 渲染使用 `react-markdown``skipHtml`,依赖库对代码 span / fenced code 的文本转义;不得在整段 Markdown 上预转义 HTML,否则会把代码中的 `<tag>` 双重转义为字面量 `&lt;tag&gt;`
- 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
- 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
@@ -889,7 +889,7 @@ game-project/
- 聊天输入 `/status` 会读取 `.agent/manifest.json` 并在聊天里汇总项目目录、任务状态、资产数量、预览状态和最近命令,不向普通用户暴露任务或文件面板。
- 聊天输入 `/files` 会通过 `file.list` 只读列出本地项目内的文件摘要;主窗口最近项目文件可一键读取,也可一键填入 `/read``/asset-register` 草稿,但资产登记仍必须走聊天确认;`/checkpoints` 复用 `file.list` / `file.read` 只读列出最近 checkpoint id、文件数、大小和可复制的 `/diff` / `/restore` 命令,主窗口最近 checkpoint 列表也可填入对应草稿;普通用户仍不暴露文件读写面板。
- 聊天输入 `/assets` 会读取 `.agent/manifest.json` 并在聊天里列出本地项目资产路径、类型和来源,资产列表消息和主窗口最近项目资产入口都可一键填入对应资产的 `/read` 草稿;聊天输入 `/art` 只使用当前已加载 manifest 盘点美术素材,并提供首版美术生成或读取美术清单草稿,不直接读取文件、不触发平台生成或画板同步;聊天输入 `/audio` 只使用当前已加载 manifest 盘点音频素材,并提供登记音效或读取音频清单草稿,不直接读取文件、不触发资产写入;`/asset-register 路径 [kind] [mediaType]` 可确认后登记项目内已有资产;主窗口音效快捷入口只填入 `/asset-register assets/audio/sfx.wav audio audio/wav` 草稿,不直接写 manifest;普通用户仍不暴露资产面板。
- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;普通用户仍不暴露文件写入或删除能力。
- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;聊天回执中的正文必须包装为安全的 `<pre><code>` 代码块,保留源码字面量但不得执行 HTML;普通用户仍不暴露文件写入或删除能力。
- 主窗口常用生成产物入口只把 `game/index.html``game/game_design.md``game/balance.json``assets/manifest.art.json``assets/manifest.audio.json``exports/README.md``/read` 草稿填入聊天输入框;聊天输入 `/artifacts` 只列出这组固定读取命令并提供首个 `/read` 草稿,聊天输入 `/run-artifacts` 只列出最近 run trace 里的产物读取命令并提供首个 `/read` 草稿,聊天输入 `/run-files` 只列出 `.agent/output.jsonl``.agent/activity.jsonl``.agent/context.bundle.json` 的读取命令并提供首个 `/read` 草稿;读取仍由聊天侧 `file.read` 权限流执行。
- 聊天输入 `/logs` 只列出 `.agent/logs/command.log``.agent/logs/preview.log``.agent/logs/agent.log` 对应的 `/read ...` 草稿 / 命令,并提供首个 `/read` 草稿;该命令不直接读取日志,不新增普通用户日志面板,实际读取仍由聊天侧 `file.read` 权限流执行。
- 聊天输入 `/tasks` 会读取 `.agent/manifest.json` 并在聊天里列出专业组、角色、任务状态、产物交接和下一步可执行任务;普通用户仍不暴露任务面板。
@@ -1294,6 +1294,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
- 该档位不把最终验收条件提前成启动条件,也不把平台画布、preview、static smoke、发布包或其它平台产物检查作为 child 或根 Supervisor 的完成门。缺少平台产物不会把已完成任务重置为 `Pending`;根 run 只等待任务图进入终态并交回结果。
- 代码可先按约定的项目路径落地并完成自己的工作;后续任务状态变化只负责唤醒同一根 run 继续收束,不因 `art-polish``art-asset-plan` 等非代码任务失败而阻塞代码启动。平台产物和可玩性检查若需要,属于后续独立验收,不是本档位的运行前置条件。
## 2026-09-04 AGC 项目聊天 Markdown 与流式回复渲染
- 项目开发工作台的 `ProjectWorkspaceChatPane``ProjectSupervisorView``SupervisorChatOnlyView` 继续共享 `ChatMessage` / `visibleMessages` 数据结构;聊天 Markdown 只作为前端表现层能力,不新增消息字段、持久化格式、后端 DTO 或事件协议。
- 三个视图统一复用 `apps/ai-game-creator-shell/src/components/ChatMarkdownMessage`。assistant 历史消息和流式临时回复使用 `react-markdown + remark-gfm` 渲染;用户消息与命令草稿保持纯文本。调用方仍先执行现有的 `projectSupervisorVisibleConversationText` 安全文案归一化,再交给展示组件。
- 流式回复以事件中的 `accumulatedText` 作为当前完整草稿:每次更新替换上一版临时正文,不在渲染层自行拼接 `deltaText`。既有 `runId` / sequence 去重、最终 assistant 持久化和历史回放语义保持不变。
- Markdown 禁止原始 HTML;链接和图片只显示普通文本,不产生可点击或可加载的外部资源。后续若开放安全外链,必须另行评估协议白名单、窗口策略和审计边界,并保留实现 TODO。
- Markdown 元素样式使用 Tailwind 内联 class,限定在聊天消息组件内部,不改全局 `.message`、资源文档预览或启动器全局 Agent 聊天。组件异常按单条消息回退纯文本,不能使整个聊天面板崩溃。
- 流式更新沿用“仅在用户接近底部时跟随”的滚动语义;用户主动查看历史时不得被实时 Markdown 高度变化强制拉回底部。实现验收需覆盖 GFM、未闭合 Markdown 中间态、HTML/链接/图片安全、三视图一致性、流式去重、历史回放和桌面/移动视口。
## 2026-08-29 DirectProject 受控联网搜索闭环
- 本次正式产品范围只包含 `DirectProject` 单 Codex Agent`Provider``ToolHost``DirectHome` 不新增联网工具桥,也不纳入本次联网路由覆盖。受控联网唯一实现为 `agc_tools.agc_web_search`Codex app-server 通过审核的 STDIO MCP 工具目录发起调用,客户端 loopback 工具桥执行固定 Bing RSS HTTPS 请求,过滤非 HTTPS、凭据 URL、回环 / 私网 / 本地域名,返回有界标题、摘要和结果链接,并以“不可信网页内容”标签回传。