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
+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;
}