Feat/AGC对话渲染markdown #281
@@ -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
|
||||
FormEventHandler,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
UIEvent,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
UIEventHandler,
|
||||
} from 'react';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
import {
|
||||
isAgentRuntimeTerminalState,
|
||||
projectRuntimeVisibleError,
|
||||
projectSupervisorChatMessageText,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
projectSupervisorChatRuntimeStatus,
|
||||
ProjectSupervisorRuntimeControls,
|
||||
projectSupervisorVisibleConversationText,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
visibleMessages,
|
||||
workspaceStatus,
|
||||
}: SupervisorChatOnlyViewProps) {
|
||||
const shouldFollowLatestRef = useRef(true);
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
}, [messagesRef, running, transientReply, runtime?.updatedAt]);
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
if (!running) {
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
return;
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
}
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
const messageList = messagesRef.current;
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
if (!messageList) {
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
return;
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
}
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
if (shouldFollowLatestRef.current) {
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
messageList.scrollTop = messageList.scrollHeight;
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
}
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
}, [
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
messagesRef,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
running,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
runtime?.updatedAt,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
transientReply,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
visibleMessages.length,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
]);
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
function handleScroll(event: UIEvent<HTMLDivElement>) {
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
const messageList = event.currentTarget;
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
const distanceFromBottom =
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
messageList.scrollHeight -
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
messageList.scrollTop -
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
messageList.clientHeight;
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
shouldFollowLatestRef.current =
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
distanceFromBottom <= CHAT_SCROLL_BOTTOM_THRESHOLD;
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
onScroll(event);
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
}
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
onScroll={handleScroll}
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
<div
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
message.text,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
message.role,
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
)}
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
</p>
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
<ChatMarkdownMessage
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
role={message.role}
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
text={projectSupervisorChatMessageText(message)}
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
/>
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
</div>
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
))}
|
||||
{transientReply ? (
|
||||
<p
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
<div
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
kdletters
commented
这里移除了原有的 这里移除了原有的 `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
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
</p>
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
<ChatMarkdownMessage
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
role="assistant"
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
text={transientReply}
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
streaming
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
/>
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
</div>
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
|
||||
) : null}
|
||||
{running && !transientReply ? (
|
||||
<div
|
||||
|
||||
|
k88936 marked this conversation as resolved
kdletters
commented
这里移除了原有的 这里移除了原有的 `useLayoutEffect` 自动滚动逻辑,但 standalone `supervisorChatOnly` 路径仍然把 `transientReply` 持续追加到同一个消息列表(`App.tsx` 通过 `messagesRef={supervisorChatMessagesRef}` 传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。
kdletters
commented
这里移除了原有的 这里移除了原有的 `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(
|
||||
'<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) '
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
'Run:run-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(
|
||||
'Run:run-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(
|
||||
'Run:run-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(
|
||||
'Run:run-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-director:Director 确定数值口径 · 待处理',
|
||||
@@ -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(
|
||||
'Run:run-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(
|
||||
'Run:run-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(
|
||||
'Run:run-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(
|
||||
'Run:run-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(
|
||||
'- Run:run-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(
|
||||
'Run:run-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(
|
||||
'Run:run-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(
|
||||
'Run:run-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(
|
||||
'Run:run-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('Run:run-main-shortcut-trace') &&
|
||||
content.includes('产物快照:'),
|
||||
),
|
||||
).not.toBeNull();
|
||||
const traceHeading = await screen.findByText(
|
||||
(content, element) =>
|
||||
element?.tagName === 'P' &&
|
||||
content.trimStart().startsWith('Run:run-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>` 双重转义为字面量 `<tag>`。
|
||||
- 美术: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、回环 / 私网 / 本地域名,返回有界标题、摘要和结果链接,并以“不可信网页内容”标签回传。
|
||||
|
||||
这里移除了原有的
useLayoutEffect自动滚动逻辑,但 standalonesupervisorChatOnly路径仍然把transientReply持续追加到同一个消息列表(App.tsx通过messagesRef={supervisorChatMessagesRef}传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。这里移除了原有的
useLayoutEffect自动滚动逻辑,但 standalonesupervisorChatOnly路径仍然把transientReply持续追加到同一个消息列表(App.tsx通过messagesRef={supervisorChatMessagesRef}传入)。因此在流式回复期间,列表不会再跟随新内容滚动到底部,用户会停留在旧位置,看不到最新 token,直到手动滚动。请保留/迁移自动滚动(同时继续尊重用户手动向上滚动的行为)。风险:该 PR 引入 Markdown 后消息高度变化更频繁,这条路径的流式回复可用性回归。