💄 style: improve operation hint and fix scroll issue (#11573)

improve operation hint
This commit is contained in:
Arvin Xu
2026-01-18 10:49:50 +08:00
committed by GitHub
parent 013a643752
commit 8505d14a0d
6 changed files with 199 additions and 9 deletions

View File

@@ -58,7 +58,7 @@ const AssistantMessage = memo<AssistantMessageProps>(
const editing = useConversationStore(messageStateSelectors.isMessageEditing(id));
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
const creating = useConversationStore(messageStateSelectors.isMessageCreating(id));
const newScreen = useNewScreen({ creating, isLatestItem });
const newScreen = useNewScreen({ creating: creating || generating, isLatestItem });
const errorContent = useErrorContent(error);

View File

@@ -1,9 +1,9 @@
import { createStaticStyles, cx } from 'antd-style';
import { memo } from 'react';
import BubblesLoading from '@/components/BubblesLoading';
import { LOADING_FLAT } from '@/const/message';
import MarkdownMessage from '@/features/Conversation/Markdown';
import ContentLoading from '@/features/Conversation/Messages/components/ContentLoading';
import { normalizeThinkTags, processWithArtifact } from '../../../utils/markdown';
import { useMarkdown } from '../useMarkdown';
@@ -25,12 +25,12 @@ const MessageContent = memo<ContentBlockProps>(({ content, hasTools, id }) => {
const message = normalizeThinkTags(processWithArtifact(content));
const markdownProps = useMarkdown(id);
if (!content && !hasTools) return <BubblesLoading />;
if (!content && !hasTools) return <ContentLoading id={id} />;
if (content === LOADING_FLAT) {
if (hasTools) return null;
return <BubblesLoading />;
return <ContentLoading id={id} />;
}
return (

View File

@@ -1,11 +1,11 @@
import { memo } from 'react';
import BubblesLoading from '@/components/BubblesLoading';
import { LOADING_FLAT } from '@/const/message';
import MarkdownMessage from '@/features/Conversation/Markdown';
import { normalizeThinkTags, processWithArtifact } from '../../../utils/markdown';
import { useMarkdown } from '../../AssistantGroup/useMarkdown';
import ContentLoading from '../../components/ContentLoading';
interface ContentBlockProps {
content: string;
@@ -20,7 +20,7 @@ const MessageContent = memo<ContentBlockProps>(({ content, id, hasTools }) => {
if (!content || content === LOADING_FLAT) {
if (hasTools) return null;
return <BubblesLoading />;
return <ContentLoading id={id} />;
}
return content && <MarkdownMessage {...markdownProps}>{message}</MarkdownMessage>;

View File

@@ -9,17 +9,17 @@ import type { OperationType } from '@/store/chat/slices/operation/types';
const ELAPSED_TIME_THRESHOLD = 2100; // Show elapsed time after 2 seconds
const NO_NEED_SHOW_DOT_OP_TYPES = new Set<OperationType>(['reasoning']);
interface ContentLoadingProps {
id: string;
}
const ContentLoading = memo<ContentLoadingProps>(({ id }) => {
const { t } = useTranslation('chat');
const operations = useChatStore(operationSelectors.getOperationsByMessage(id));
const runningOp = useChatStore(operationSelectors.getDeepestRunningOperationByMessage(id));
const [elapsedSeconds, setElapsedSeconds] = useState(0);
// Get the running operation
const runningOp = operations.find((op) => op.status === 'running');
const operationType = runningOp?.type as OperationType | undefined;
const startTime = runningOp?.metadata?.startTime;
@@ -48,6 +48,8 @@ const ContentLoading = memo<ContentLoadingProps>(({ id }) => {
const showElapsedTime = elapsedSeconds >= ELAPSED_TIME_THRESHOLD / 1000;
if (operationType && NO_NEED_SHOW_DOT_OP_TYPES.has(operationType)) return null;
return (
<Flexbox align={'center'} horizontal>
<BubblesLoading />

View File

@@ -188,6 +188,171 @@ describe('Operation Selectors', () => {
});
});
describe('getDeepestRunningOperationByMessage', () => {
it('should return undefined when no operations exist', () => {
const { result } = renderHook(() => useChatStore());
const deepestOp = operationSelectors.getDeepestRunningOperationByMessage('msg1')(
result.current,
);
expect(deepestOp).toBeUndefined();
});
it('should return undefined when no running operations exist', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
result.current.completeOperation(opId);
});
const deepestOp = operationSelectors.getDeepestRunningOperationByMessage('msg1')(
result.current,
);
expect(deepestOp).toBeUndefined();
});
it('should return the only running operation when there is one', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
});
const deepestOp = operationSelectors.getDeepestRunningOperationByMessage('msg1')(
result.current,
);
expect(deepestOp).toBeDefined();
expect(deepestOp?.type).toBe('execAgentRuntime');
});
it('should return the leaf operation in a parent-child tree', () => {
const { result } = renderHook(() => useChatStore());
let parentOpId: string;
let childOpId: string;
act(() => {
// Start parent operation
parentOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', parentOpId);
// Start child operation
childOpId = result.current.startOperation({
type: 'reasoning',
context: { agentId: 'session1', messageId: 'msg1' },
parentOperationId: parentOpId,
}).operationId;
result.current.associateMessageWithOperation('msg1', childOpId);
});
const deepestOp = operationSelectors.getDeepestRunningOperationByMessage('msg1')(
result.current,
);
// Should return the child (reasoning) not the parent (execAgentRuntime)
expect(deepestOp).toBeDefined();
expect(deepestOp?.type).toBe('reasoning');
expect(deepestOp?.id).toBe(childOpId!);
});
it('should return the deepest leaf in a multi-level tree', () => {
const { result } = renderHook(() => useChatStore());
let rootOpId: string;
let level1OpId: string;
let level2OpId: string;
act(() => {
// Level 0: root operation
rootOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', rootOpId);
// Level 1: child of root
level1OpId = result.current.startOperation({
type: 'callLLM',
context: { agentId: 'session1', messageId: 'msg1' },
parentOperationId: rootOpId,
}).operationId;
result.current.associateMessageWithOperation('msg1', level1OpId);
// Level 2: grandchild (deepest)
level2OpId = result.current.startOperation({
type: 'reasoning',
context: { agentId: 'session1', messageId: 'msg1' },
parentOperationId: level1OpId,
}).operationId;
result.current.associateMessageWithOperation('msg1', level2OpId);
});
const deepestOp = operationSelectors.getDeepestRunningOperationByMessage('msg1')(
result.current,
);
// Should return the deepest leaf (reasoning at level 2)
expect(deepestOp).toBeDefined();
expect(deepestOp?.type).toBe('reasoning');
expect(deepestOp?.id).toBe(level2OpId!);
});
it('should return parent when child operation completes', () => {
const { result } = renderHook(() => useChatStore());
let parentOpId: string;
let childOpId: string;
act(() => {
parentOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', parentOpId);
childOpId = result.current.startOperation({
type: 'reasoning',
context: { agentId: 'session1', messageId: 'msg1' },
parentOperationId: parentOpId,
}).operationId;
result.current.associateMessageWithOperation('msg1', childOpId);
});
// Before completing child
let deepestOp = operationSelectors.getDeepestRunningOperationByMessage('msg1')(
result.current,
);
expect(deepestOp?.type).toBe('reasoning');
// Complete child operation
act(() => {
result.current.completeOperation(childOpId);
});
// After completing child, parent should be the deepest running
deepestOp = operationSelectors.getDeepestRunningOperationByMessage('msg1')(result.current);
expect(deepestOp?.type).toBe('execAgentRuntime');
expect(deepestOp?.id).toBe(parentOpId!);
});
});
describe('isMessageProcessing', () => {
it('should return true if message has running operations', () => {
const { result } = renderHook(() => useChatStore());

View File

@@ -355,6 +355,28 @@ const isAnyMessageLoading =
return messageIds.some((id) => isMessageProcessing(id)(s));
};
/**
* Get the deepest running operation for a message (leaf node in operation tree)
* Operations form a tree structure via parentOperationId/childOperationIds
* This returns the most specific (deepest) running operation for UI display
*/
const getDeepestRunningOperationByMessage =
(messageId: string) =>
(s: ChatStoreState): Operation | undefined => {
const operations = getOperationsByMessage(messageId)(s);
const runningOps = operations.filter((op) => op.status === 'running');
if (runningOps.length === 0) return undefined;
const runningOpIds = new Set(runningOps.map((op) => op.id));
// A leaf running operation has no running children
return runningOps.find((op) => {
const childIds = op.childOperationIds || [];
return !childIds.some((childId) => runningOpIds.has(childId));
});
};
/**
* Check if a specific message is being regenerated
*/
@@ -440,6 +462,7 @@ export const operationSelectors = {
getCurrentContextOperations,
getCurrentOperationLabel,
getCurrentOperationProgress,
getDeepestRunningOperationByMessage,
getOperationById,
getOperationContextFromMessage,
getOperationsByContext,