From 48c524c0e06a104a40ec570b7db6365b50c898d4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 18 Sep 2026 20:21:50 +0800 Subject: [PATCH] fix(mobile-web): show a hydrate status while a chat transcript loads Opening a session cleared the transcript before cache or the host snapshot arrived, so the chat painted as an empty black pane. --- src/mobile-web/src/App.tsx | 2 +- src/mobile-web/src/i18n/messages.ts | 3 + src/mobile-web/src/pages/ChatPage.tsx | 137 ++++++++++-------- .../src/styles/components/chat.scss | 9 ++ .../tests/mobile-ui-components.test.mjs | 21 +++ 5 files changed, 113 insertions(+), 59 deletions(-) diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index 6d576b4672..013e99e3b4 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -463,7 +463,7 @@ const AppContent: React.FC = () => { } if (currentPage === 'chat' && sessionMgrRef.current && activeSessionId) { return ( - }> + }> = { }, chat: { session: 'Session', + loadingSession: 'Loading session...', loadingOlderMessages: 'Loading older messages...', showResponse: 'Show response', hideResponse: 'Hide response', @@ -552,6 +553,7 @@ export const messages: Record = { }, chat: { session: '会话', + loadingSession: '正在加载会话...', loadingOlderMessages: '正在加载更早的消息...', showResponse: 'Show response', hideResponse: 'Hide response', @@ -875,6 +877,7 @@ export const messages: Record = { }, chat: { session: '會話', + loadingSession: '正在加載會話...', loadingOlderMessages: '正在加載更早的消息...', showResponse: 'Show response', hideResponse: 'Hide response', diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 47f97b17a0..22c7846d14 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -3,7 +3,7 @@ import { PermissionMailbox } from '../components/PermissionMailbox'; import { QuestionInteractionContext } from "../components/ChatAskQuestionCard"; import { ChevronDown as LucideChevronDown } from 'lucide-react'; import React, { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react'; -import { MobileIconButton } from '@openbitfun/ui/mobile'; +import { MobileIconButton, MobileStatus } from '@openbitfun/ui/mobile'; import { useI18n } from '../i18n'; import { useControlTargetEpoch } from '../hooks/useControlTargetEpoch'; import { @@ -138,6 +138,7 @@ const ChatPage: React.FC = ({ const messagesRequestSeqRef = useRef(0); const [isLoadingMore, setIsLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); + const [transcriptHydrating, setTranscriptHydrating] = useState(true); const isLoadingMoreRef = useRef(false); const hasMoreRef = useRef(true); const controlTargetEpoch = useControlTargetEpoch(sessionMgr); @@ -230,6 +231,7 @@ const ChatPage: React.FC = ({ setModelCatalog(null); setSelectedModelId('auto'); setMessages(sessionId, []); + setTranscriptHydrating(true); setMenuMessage(null); setDeletingMsg(false); setActionToast(null); @@ -697,67 +699,80 @@ const ChatPage: React.FC = ({ && isChatTargetCurrent(targetEpoch) ); const initialize = async () => { - const catalogPromise = loadModelCatalog(); - const cached = await remoteCache.loadTranscript(cacheScope, sessionId); - if (!isInitCurrent()) return; - if (cached) { - setMessages(sessionId, cached.messages); - setHasMore(cached.hasMore); - hasMoreRef.current = cached.hasMore; - pendingInitialScroll.current = true; - } - - // Always reconcile with the authoritative host. The cached transcript is - // only an immediate paint and remains isolated to this account/device. - // Durable records reconcile the cached view through the same stream. - const initialCatalog = await catalogPromise; - if (!isInitCurrent()) return; - const initialMsgCount = useMobileStore.getState().getMessages(sessionId).length; - pendingInitialScroll.current = true; - - const synchronizer = new SessionSynchronizer(sessionMgr, sessionId, (resp: PollResponse) => { + const markTranscriptReady = () => { + if (isInitCurrent()) setTranscriptHydrating(false); + }; + try { + const catalogPromise = loadModelCatalog(); + const cached = await remoteCache.loadTranscript(cacheScope, sessionId); if (!isInitCurrent()) return; - if (resp.message_snapshot) { - // Completion can grow the content of an already-counted assistant - // message. Replace from the host's durable transcript; message count - // alone cannot detect that repair. - setMessages(sessionId, resp.message_snapshot); - remoteCache.saveTranscript( - cacheScope, - sessionId, - resp.message_snapshot, - hasMoreRef.current, - ); - } else if (resp.new_messages && resp.new_messages.length > 0) { - appendNewMessages(sessionId, resp.new_messages); - remoteCache.saveTranscript( - cacheScope, - sessionId, - useMobileStore.getState().getMessages(sessionId), - hasMoreRef.current, - ); + if (cached) { + setMessages(sessionId, cached.messages); + setHasMore(cached.hasMore); + hasMoreRef.current = cached.hasMore; + pendingInitialScroll.current = true; + if (cached.messages.length > 0) markTranscriptReady(); } - if (resp.title) { - setLiveTitle(resp.title); - updateSessionName(sessionId, resp.title); - remoteCache.renameSession(cacheScope, sessionId, resp.title); - } - if (resp.model_catalog) { - setModelCatalog(resp.model_catalog); - setSelectedModelId(normalizeSelectedModelId( - resp.model_catalog.session_model_id || 'auto', - resp.model_catalog, - )); + // Always reconcile with the authoritative host. The cached transcript is + // only an immediate paint and remains isolated to this account/device. + // Durable records reconcile the cached view through the same stream. + const initialCatalog = await catalogPromise; + if (!isInitCurrent()) return; + const initialMsgCount = useMobileStore.getState().getMessages(sessionId).length; + pendingInitialScroll.current = true; + + const synchronizer = new SessionSynchronizer(sessionMgr, sessionId, (resp: PollResponse) => { + if (!isInitCurrent()) return; + if (resp.message_snapshot) { + // Completion can grow the content of an already-counted assistant + // message. Replace from the host's durable transcript; message count + // alone cannot detect that repair. + setMessages(sessionId, resp.message_snapshot); + remoteCache.saveTranscript( + cacheScope, + sessionId, + resp.message_snapshot, + hasMoreRef.current, + ); + markTranscriptReady(); + } else if (resp.new_messages && resp.new_messages.length > 0) { + appendNewMessages(sessionId, resp.new_messages); + remoteCache.saveTranscript( + cacheScope, + sessionId, + useMobileStore.getState().getMessages(sessionId), + hasMoreRef.current, + ); + markTranscriptReady(); + } + + if (resp.title) { + setLiveTitle(resp.title); + updateSessionName(sessionId, resp.title); + remoteCache.renameSession(cacheScope, sessionId, resp.title); + } + if (resp.model_catalog) { + setModelCatalog(resp.model_catalog); + setSelectedModelId(normalizeSelectedModelId( + resp.model_catalog.session_model_id || 'auto', + resp.model_catalog, + )); + } + setActiveTurn(resp.active_turn ?? null); + }, initialCatalog?.version || 0, history => { if(isInitCurrent()){setHasMore(history.hasMore);hasMoreRef.current=history.hasMore;} }, () => { if(isInitCurrent())setMailboxInvalidation(value=>value+1); }, + // Stream failures are stated, not hidden: an older host or a lost + // connection shows up in the same banner as any other remote error. + error => { if (isInitCurrent()) { reportRemoteSessionError(error, setError); setTranscriptHydrating(false); } }); + + synchronizer.start(initialMsgCount); + streamRef.current = synchronizer; + } catch (error) { + if (isInitCurrent()) { + reportRemoteSessionError(error, setError); + setTranscriptHydrating(false); } - setActiveTurn(resp.active_turn ?? null); - }, initialCatalog?.version || 0, history => { if(isInitCurrent()){setHasMore(history.hasMore);hasMoreRef.current=history.hasMore;} }, () => { if(isInitCurrent())setMailboxInvalidation(value=>value+1); }, - // Stream failures are stated, not hidden: an older host or a lost - // connection shows up in the same banner as any other remote error. - error => { if (isInitCurrent()) reportRemoteSessionError(error, setError); }); - - synchronizer.start(initialMsgCount); - streamRef.current = synchronizer; + } }; void initialize(); @@ -1017,6 +1032,10 @@ const ChatPage: React.FC = ({ {/* Messages */}
+ {transcriptHydrating && messages.length === 0 ? ( + + ) : ( + <> {isLoadingMore && (
{t('chat.loadingOlderMessages')}
)} @@ -1057,6 +1076,8 @@ const ChatPage: React.FC = ({ /> + + )}
diff --git a/src/mobile-web/src/styles/components/chat.scss b/src/mobile-web/src/styles/components/chat.scss index 0ce4823c24..732743717b 100644 --- a/src/mobile-web/src/styles/components/chat.scss +++ b/src/mobile-web/src/styles/components/chat.scss @@ -170,6 +170,15 @@ position: relative; } +.chat-page__hydrate { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + align-self: stretch; +} + .chat-page__load-more-indicator { text-align: center; font-size: var(--openbitfun-type-body-xs-font-size); diff --git a/src/mobile-web/tests/mobile-ui-components.test.mjs b/src/mobile-web/tests/mobile-ui-components.test.mjs index 50ede3966a..3abd976ff1 100644 --- a/src/mobile-web/tests/mobile-ui-components.test.mjs +++ b/src/mobile-web/tests/mobile-ui-components.test.mjs @@ -224,6 +224,27 @@ test('large mobile pages delegate stable UI regions to app components', async () assert.doesNotMatch(sessionPage, /createPortal\b/, 'shared sheets own their portal lifecycle'); }); +test('opening a chat keeps a hydrate status until cache or the host snapshot arrives', async () => { + const chatPage = await readFile(path.join(sourceDirectory, 'pages/ChatPage.tsx'), 'utf8'); + const app = await readFile(path.join(sourceDirectory, 'App.tsx'), 'utf8'); + const chatStyles = await readFile(path.join(sourceDirectory, 'styles/components/chat.scss'), 'utf8'); + const messages = await readFile(path.join(sourceDirectory, 'i18n/messages.ts'), 'utf8'); + + assert.match(chatPage, /\s*0[\s\S]*setTranscriptHydrating\(false\)/); + assert.match(chatPage, /resp\.message_snapshot[\s\S]*setTranscriptHydrating\(false\)/); + assert.match(chatPage, /className="chat-page__hydrate"/); + assert.match(chatPage, /t\('chat\.loadingSession'\)/); + assert.match(app, /t\('chat\.loadingSession'\)/); + assert.doesNotMatch(app, /fallback=\{ { const manager = await readFile(path.join(sourceDirectory, 'services/RemoteSessionManager.ts'), 'utf8'); const chatPage = await readFile(path.join(sourceDirectory, 'pages/ChatPage.tsx'), 'utf8');