feat(h5): show rotating loading tips during agent-run execution

Surface backend loading_tip SSE events in chat UI with shared tip pool
and styles for long-running TKMind 智趣 tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-27 09:33:50 +08:00
parent 8f91c52c67
commit 8bdb6e2442
9 changed files with 191 additions and 9 deletions
+26
View File
@@ -1573,10 +1573,22 @@ type SubscribeOptions = {
onBalance?: (update: BalanceUpdate) => void;
};
export type AgentRunStreamEvent = {
eventId?: string;
eventType: string;
data: unknown;
createdAt?: string;
};
type SubscribeAgentRunEventsOptions = {
onRunEvent?: (event: AgentRunStreamEvent) => void;
};
export function subscribeAgentRunEvents(
runId: string,
onRun: (run: AgentRun) => void,
onError: (error: Error) => void,
options: SubscribeAgentRunEventsOptions = {},
): () => void {
let closed = false;
let activeController: AbortController | null = null;
@@ -1634,6 +1646,20 @@ export function subscribeAgentRunEvents(
const payload = JSON.parse(data) as { message?: string };
throw new Error(payload.message || '后台任务失败');
}
if (eventName === 'run_event') {
const payload = JSON.parse(data) as {
eventId?: string;
eventType?: string;
data?: unknown;
createdAt?: string;
};
options.onRunEvent?.({
eventId: payload.eventId,
eventType: String(payload.eventType ?? ''),
data: payload.data,
createdAt: payload.createdAt,
});
}
}
}
} catch (err) {
+2
View File
@@ -198,6 +198,7 @@ export function ChatView({
error,
notice,
pendingTool,
taskLoadingTip,
submit,
stop,
approveTool,
@@ -577,6 +578,7 @@ export function ChatView({
onBalanceUpdate={(nextBalance) => completeRecharge(nextBalance)}
onGrantedSkillsUpdate={onGrantedSkillsUpdate}
onOpenRecharge={() => openRecharge(false)}
taskLoadingTip={taskLoadingTip}
onSubmit={(text, imageUrls, previewImageUrls, options) =>
void submit(
text,
+9 -3
View File
@@ -145,7 +145,7 @@ function isToolOnlyAssistantMessage(message: Message) {
);
}
function TypingBubble() {
function TypingBubble({ tip }: { tip?: string | null }) {
return (
<div className="msg-bubble msg-bubble-assistant msg-typing">
<span className="typing-dots">
@@ -153,6 +153,7 @@ function TypingBubble() {
<span />
<span />
</span>
{tip ? <p className="msg-typing-tip">{tip}</p> : null}
</div>
);
}
@@ -324,6 +325,7 @@ function MessageRow({
compact = false,
activeToolMessage = false,
showInlineTyping = false,
typingTip = null,
memoryRecallByMessageId = {},
messageIndex = 0,
}: {
@@ -341,6 +343,7 @@ function MessageRow({
compact?: boolean;
activeToolMessage?: boolean;
showInlineTyping?: boolean;
typingTip?: string | null;
memoryRecallByMessageId?: Record<string, { count: number; previews: string[] }>;
messageIndex?: number;
}) {
@@ -362,7 +365,7 @@ function MessageRow({
<TKMindAvatar />
<div className="msg-content">
{showInlineTyping ? (
<TypingBubble />
<TypingBubble tip={typingTip} />
) : (
<>
<ToolBadge message={message} active={activeToolMessage} />
@@ -537,6 +540,7 @@ export function MessageList({
publishUsername,
compact = false,
memoryRecallByMessageId = {},
typingTip = null,
}: {
messages: Message[];
streaming: boolean;
@@ -549,6 +553,7 @@ export function MessageList({
publishUsername?: string;
compact?: boolean;
memoryRecallByMessageId?: Record<string, { count: number; previews: string[] }>;
typingTip?: string | null;
}) {
const { avatarUrl } = useUserAvatar();
const renderableMessages = messages.filter(shouldShowChatMessage);
@@ -605,6 +610,7 @@ export function MessageList({
compact={compact}
activeToolMessage={message === activeToolMessage}
showInlineTyping={showEndTyping && message === inlineTypingMessage}
typingTip={showEndTyping && message === inlineTypingMessage ? typingTip : null}
memoryRecallByMessageId={memoryRecallByMessageId}
messageIndex={index}
/>
@@ -615,7 +621,7 @@ export function MessageList({
<div className="msg-row msg-row-assistant" role="status" aria-live="polite" aria-label="助手正在回复">
<TKMindAvatar />
<div className="msg-content">
<TypingBubble />
<TypingBubble tip={typingTip} />
</div>
</div>
)}
+2 -1
View File
@@ -58,7 +58,7 @@ export function SpaceChatPanel({
uploadChatAttachment,
retryConnect,
} = chat;
const { capabilities, grantedSkills, followAgentRun, goalRunEnabled, balanceCents } = mainChat;
const { capabilities, grantedSkills, followAgentRun, goalRunEnabled, balanceCents, taskLoadingTip } = mainChat;
const [localGrantedSkills, setLocalGrantedSkills] = useState<string[] | undefined>();
const effectiveGrantedSkills = localGrantedSkills ?? grantedSkills;
@@ -180,6 +180,7 @@ export function SpaceChatPanel({
onBalanceUpdate={(nextBalance) => completeRecharge(nextBalance)}
onGrantedSkillsUpdate={handleGrantedSkillsUpdate}
onOpenRecharge={() => openRecharge(false)}
taskLoadingTip={taskLoadingTip}
onSubmit={(text, imageUrls, previewImageUrls, options) =>
chatBridge
? void submit(
+44 -1
View File
@@ -121,6 +121,12 @@ function createAgentRunFailedError(message: string) {
return error;
}
function extractLoadingTipFromRunEvent(data: unknown): string | null {
if (!data || typeof data !== 'object') return null;
const text = (data as { text?: unknown }).text;
return typeof text === 'string' && text.trim() ? text.trim() : null;
}
function errorCode(error: unknown) {
if (error instanceof ApiError) return error.code;
if (error && typeof error === 'object' && 'code' in error) {
@@ -396,6 +402,7 @@ export function useTKMindChat(
const [rechargeForced, setRechargeForced] = useState(false);
const [subscribePrompt, setSubscribePrompt] = useState(false);
const [activeNotification, setActiveNotification] = useState<UserNotification | null>(null);
const [taskLoadingTip, setTaskLoadingTip] = useState<string | null>(null);
const seenNotificationIdsRef = useRef<Set<string>>(new Set());
const activeRequestId = useRef<string | null>(null);
@@ -424,11 +431,43 @@ export function useTKMindChat(
const pendingSubmitQueueRef = useRef<PendingChatSubmitEntry[]>([]);
const flushingPendingSubmitRef = useRef(false);
const pendingToolRef = useRef<ToolConfirmation | null>(null);
const taskLoadingTipUnsubRef = useRef<(() => void) | null>(null);
useEffect(() => {
chatStateRef.current = chatState;
}, [chatState]);
const clearTaskLoadingTipSubscription = useCallback(() => {
taskLoadingTipUnsubRef.current?.();
taskLoadingTipUnsubRef.current = null;
setTaskLoadingTip(null);
}, []);
const startTaskLoadingTipSubscription = useCallback((runId: string, isCancelled: () => boolean) => {
clearTaskLoadingTipSubscription();
taskLoadingTipUnsubRef.current = subscribeAgentRunEvents(
runId,
() => {},
() => {},
{
onRunEvent: (event) => {
if (isCancelled()) return;
if (event.eventType !== 'loading_tip') return;
const tip = extractLoadingTipFromRunEvent(event.data);
if (tip) setTaskLoadingTip(tip);
},
},
);
}, [clearTaskLoadingTipSubscription]);
useEffect(() => {
if (chatState === 'idle' || chatState === 'error' || chatState === 'loading') {
clearTaskLoadingTipSubscription();
}
}, [chatState, clearTaskLoadingTipSubscription]);
useEffect(() => () => clearTaskLoadingTipSubscription(), [clearTaskLoadingTipSubscription]);
useEffect(() => {
pendingToolRef.current = pendingTool;
}, [pendingTool]);
@@ -1560,6 +1599,7 @@ export function useTKMindChat(
...(options?.goalRunId ? { goalRunId: options.goalRunId } : {}),
},
);
startTaskLoadingTipSubscription(createdRun.id, () => submitToken !== connectTokenRef.current);
const runSessionId = createdRun.sessionId ?? activeSessionId;
if (runSessionId && !isDirectChatSessionId(runSessionId)) {
activeSessionId = runSessionId;
@@ -1729,6 +1769,7 @@ export function useTKMindChat(
ensureProvider,
loadProjectMemory,
refreshSessions,
startTaskLoadingTipSubscription,
],
);
@@ -1902,6 +1943,7 @@ export function useTKMindChat(
chatStateRef.current = 'waiting';
setError(null);
agentRunPendingRef.current = true;
startTaskLoadingTipSubscription(createdRun.id, () => submitToken !== connectTokenRef.current);
try {
const finishedRun =
@@ -1960,7 +2002,7 @@ export function useTKMindChat(
activeRequestId.current = null;
}
},
[clearActiveRequestMissingTimer, scheduleReplyRecoverySync, subscribeToSession],
[clearActiveRequestMissingTimer, scheduleReplyRecoverySync, subscribeToSession, startTaskLoadingTipSubscription],
);
const newSession = useCallback(async () => {
@@ -2159,6 +2201,7 @@ export function useTKMindChat(
error,
notice,
pendingTool,
taskLoadingTip,
memoryLoading,
userMemoryLoading,
canUseProjectMemory,
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useRef, useState } from 'react';
import { pickTkmindLoadingTip } from '../utils/tkmindLoadingTips';
const ROTATE_MS = 4_500;
export function useTaskLoadingTip(active: boolean, externalTip?: string | null) {
const [localTip, setLocalTip] = useState<string | null>(null);
const recentTipsRef = useRef<string[]>([]);
useEffect(() => {
if (!active || externalTip) {
setLocalTip(null);
return;
}
const rotate = () => {
const picked = pickTkmindLoadingTip({ exclude: recentTipsRef.current.slice(-3) });
recentTipsRef.current = [...recentTipsRef.current, picked.text].slice(-6);
setLocalTip(picked.text);
};
rotate();
const timer = window.setInterval(rotate, ROTATE_MS);
return () => window.clearInterval(timer);
}, [active, externalTip]);
return externalTip ?? localTip;
}
+38 -2
View File
@@ -2322,6 +2322,16 @@ body,
.msg-typing {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.msg-typing-tip {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-muted);
}
.typing-dots {
@@ -3077,8 +3087,9 @@ body,
.chat-input-task-status {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
align-items: flex-start;
gap: 6px;
width: 100%;
border: none;
background: transparent;
@@ -3086,10 +3097,35 @@ body,
user-select: none;
}
.chat-input-task-status-main {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.chat-input-task-status-text {
color: var(--color-text-muted);
}
.chat-input-task-status-tip {
font-size: 12px;
line-height: 1.45;
color: var(--color-text-faint);
animation: task-loading-tip-fade 0.35s ease;
}
@keyframes task-loading-tip-fade {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-input-task-status-dots span {
background: var(--color-text-muted);
}
+2 -2
View File
@@ -3,7 +3,7 @@ import type { MindSpaceChatContext, AgentCodeRunClientPolicy } from '../types';
export type AgentRunCreateOptions = {
toolMode?: 'chat' | 'code';
taskType?: string | null;
executor?: 'aider' | 'openhands';
executor?: 'aider' | 'openhands' | 'cursor';
forceDeepReasoning?: boolean;
validation?: AgentRunValidation | null;
validationInstruction?: string | null;
@@ -302,7 +302,7 @@ export function resolveAgentRunOptions(
}: {
taskType?: string;
forceCode?: boolean;
requiredExecutor?: 'aider' | 'openhands';
requiredExecutor?: 'aider' | 'openhands' | 'cursor';
allowAutodetect?: boolean;
allowPageDataDevAutodetect?: boolean;
userId?: string | null;
+40
View File
@@ -0,0 +1,40 @@
export type TkmindLoadingTipCategory = 'intro' | 'news' | 'joke' | 'poem';
export type TkmindLoadingTip = {
category: TkmindLoadingTipCategory;
text: string;
};
export const TKMIND_LOADING_TIPS: TkmindLoadingTip[] = [
{ category: 'intro', text: 'TKMind 智趣:把聊天变成可交付的页面、问卷与分析。' },
{ category: 'intro', text: '智趣正在帮你落盘 MindSpace 页面,稍等片刻就好。' },
{ category: 'intro', text: 'TKMind 会把对话里的想法,变成能分享的链接。' },
{ category: 'intro', text: '页面、问卷、Excel 分析——智趣一条指令就能开工。' },
{ category: 'intro', text: 'MindSpace 是你的创作工作台,TKMind 是懂你的搭档。' },
{ category: 'news', text: '今日小贴士:先让智趣出草稿,再微调细节,效率翻倍。' },
{ category: 'news', text: '热点观察:越来越多团队用 AI 助手做「可点击」的交付物。' },
{ category: 'news', text: '趋势速览:问卷 + Page Data 可以直接沉淀到数据库。' },
{ category: 'news', text: '轻新闻:一杯咖啡的时间,页面可能就写好了。' },
{ category: 'joke', text: '程序员笑话:Bug 不是消失,只是换了个地方躲猫猫。' },
{ category: 'joke', text: '冷笑话:为什么页面加载慢?因为它在认真排版。' },
{ category: 'joke', text: '今日一笑:产品经理说「就改一行」,智趣默默打开了十个文件。' },
{ category: 'joke', text: '段子时间:代码写得好,头发剩多少?' },
{ category: 'poem', text: '「采菊东篱下,悠然见南山。」——陶渊明' },
{ category: 'poem', text: '「欲把西湖比西子,淡妆浓抹总相宜。」——苏轼' },
{ category: 'poem', text: '「海上生明月,天涯共此时。」——张九龄' },
{ category: 'poem', text: '「春风得意马蹄疾,一日看尽长安花。」——孟郊' },
{ category: 'poem', text: '「行到水穷处,坐看云起时。」——王维' },
];
export function pickTkmindLoadingTip(options: { seed?: string | number; exclude?: string[] } = {}): TkmindLoadingTip {
const exclude = new Set((options.exclude ?? []).map((item) => String(item ?? '').trim()).filter(Boolean));
const pool = TKMIND_LOADING_TIPS.filter((item) => !exclude.has(item.text));
const candidates = pool.length > 0 ? pool : TKMIND_LOADING_TIPS;
const seed = String(options.seed ?? `${Date.now()}-${Math.random()}`);
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = ((hash << 5) - hash + seed.charCodeAt(index)) | 0;
}
const picked = candidates[Math.abs(hash) % candidates.length];
return picked ?? candidates[0];
}