Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/mobile-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ const AppContent: React.FC = () => {
}
if (currentPage === 'chat' && sessionMgrRef.current && activeSessionId) {
return (
<Suspense fallback={<MobileStatus loading title={t('workspace.loadingInfo')} />}>
<Suspense fallback={<div className="chat-page"><MobileStatus className="chat-page__hydrate" loading title={t('chat.loadingSession')} /></div>}>
<ChatPage
sessionMgr={sessionMgrRef.current}
key={activeSessionId}
Expand Down
3 changes: 3 additions & 0 deletions src/mobile-web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export const messages: Record<MobileLanguage, MessageTree> = {
},
chat: {
session: 'Session',
loadingSession: 'Loading session...',
loadingOlderMessages: 'Loading older messages...',
showResponse: 'Show response',
hideResponse: 'Hide response',
Expand Down Expand Up @@ -552,6 +553,7 @@ export const messages: Record<MobileLanguage, MessageTree> = {
},
chat: {
session: '会话',
loadingSession: '正在加载会话...',
loadingOlderMessages: '正在加载更早的消息...',
showResponse: 'Show response',
hideResponse: 'Hide response',
Expand Down Expand Up @@ -875,6 +877,7 @@ export const messages: Record<MobileLanguage, MessageTree> = {
},
chat: {
session: '會話',
loadingSession: '正在加載會話...',
loadingOlderMessages: '正在加載更早的消息...',
showResponse: 'Show response',
hideResponse: 'Hide response',
Expand Down
137 changes: 79 additions & 58 deletions src/mobile-web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -138,6 +138,7 @@ const ChatPage: React.FC<ChatPageProps> = ({
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);
Expand Down Expand Up @@ -230,6 +231,7 @@ const ChatPage: React.FC<ChatPageProps> = ({
setModelCatalog(null);
setSelectedModelId('auto');
setMessages(sessionId, []);
setTranscriptHydrating(true);
setMenuMessage(null);
setDeletingMsg(false);
setActionToast(null);
Expand Down Expand Up @@ -697,67 +699,80 @@ const ChatPage: React.FC<ChatPageProps> = ({
&& 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();

Expand Down Expand Up @@ -1017,6 +1032,10 @@ const ChatPage: React.FC<ChatPageProps> = ({
<PermissionMailbox key={`${sessionId}:${controlTargetEpoch}`} manager={sessionMgr} sessionId={sessionId} invalidation={mailboxInvalidation} />
{/* Messages */}
<div className="chat-page__messages" ref={messagesContainerRef} onScroll={handleScroll}>
{transcriptHydrating && messages.length === 0 ? (
<MobileStatus className="chat-page__hydrate" loading title={t('chat.loadingSession')} />
) : (
<>
{isLoadingMore && (
<div className="chat-page__load-more-indicator">{t('chat.loadingOlderMessages')}</div>
)}
Expand Down Expand Up @@ -1057,6 +1076,8 @@ const ChatPage: React.FC<ChatPageProps> = ({
/>
</ArtifactImageReader.Provider>
</QuestionInteractionContext.Provider>
</>
)}

<div ref={messagesEndRef} />

Expand Down
9 changes: 9 additions & 0 deletions src/mobile-web/src/styles/components/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions src/mobile-web/tests/mobile-ui-components.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<MobileStatus\b/);
assert.match(chatPage, /transcriptHydrating/);
assert.match(chatPage, /setTranscriptHydrating\(true\)/);
assert.match(chatPage, /cached\.messages\.length\s*>\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=\{<MobileStatus[^}]*workspace\.loadingInfo/);
assert.match(chatStyles, /\.chat-page__hydrate\s*\{[\s\S]*?flex:\s*1;/);
assert.match(messages, /loadingSession:\s*'Loading session\.\.\.'/);
assert.match(messages, /loadingSession:\s*'正在加载会话\.\.\.'/);
assert.match(messages, /loadingSession:\s*'正在加載會話\.\.\.'/);
});

test('mobile remote control exposes approval commands and responsive composer contracts', async () => {
const manager = await readFile(path.join(sourceDirectory, 'services/RemoteSessionManager.ts'), 'utf8');
const chatPage = await readFile(path.join(sourceDirectory, 'pages/ChatPage.tsx'), 'utf8');
Expand Down
Loading