mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
💄 style: improve operation hint and fix scroll issue (#11573)
improve operation hint
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user