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
84 changes: 80 additions & 4 deletions src/mobile-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import PairingPage from './pages/PairingPage';
import WorkspacePage from './pages/WorkspacePage';
import SessionListPage from './pages/SessionListPage';
import ChatPage from './pages/ChatPage';
import { I18nProvider } from './i18n';
import { ErrorBoundary } from './components/ErrorBoundary';
import { I18nProvider, useI18n } from './i18n';
import { RelayHttpClient } from './services/RelayHttpClient';
import { RemoteSessionManager } from './services/RemoteSessionManager';
import { ThemeProvider } from './theme';
import { useMobileStore } from './services/store';
import './styles/index.scss';

type Page = 'pairing' | 'workspace' | 'sessions' | 'chat';
Expand All @@ -29,12 +31,15 @@ function getNavClass(
}

const AppContent: React.FC = () => {
const { t } = useI18n();
const [page, setPage] = useState<Page>('pairing');
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [activeSessionName, setActiveSessionName] = useState<string>('Session');
const [chatAutoFocus, setChatAutoFocus] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
const clientRef = useRef<RelayHttpClient | null>(null);
const sessionMgrRef = useRef<RemoteSessionManager | null>(null);
const [sessionMgr, setSessionMgr] = useState<RemoteSessionManager | null>(null);

const [navDir, setNavDir] = useState<NavDirection>(null);
const [prevPage, setPrevPage] = useState<Page | null>(null);
Expand Down Expand Up @@ -102,13 +107,58 @@ const AppContent: React.FC = () => {
(client: RelayHttpClient, sessionMgr: RemoteSessionManager) => {
clientRef.current = client;
sessionMgrRef.current = sessionMgr;
setSessionMgr(sessionMgr);
pageStackRef.current = ['pairing', 'sessions'];
history.pushState({ page: 'sessions' }, '');
setPage('sessions');
},
[],
);

// Periodic connection health check
useEffect(() => {
const shouldMonitor = page === 'sessions' || page === 'chat';
if (!shouldMonitor || !sessionMgr) {
setIsReconnecting(false);
return;
}

let cancelled = false;
let timer: ReturnType<typeof setTimeout>;

const pingWithTimeout = (ms: number): Promise<void> => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return Promise.race([
sessionMgr.ping(),
new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('ping timeout')), ms);
}),
]).finally(() => {
if (timeoutId) clearTimeout(timeoutId);
});
};

const loop = async () => {
try {
await pingWithTimeout(10000);
if (!cancelled) setIsReconnecting(false);
} catch {
if (!cancelled) setIsReconnecting(true);
}

if (!cancelled) {
timer = setTimeout(loop, 15000);
}
};

loop();

return () => {
cancelled = true;
clearTimeout(timer);
};
}, [sessionMgr, page]);

// Pop navigation handlers that can be called from both UI buttons and popstate
const doPopFromChat = useCallback(() => {
navigateTo('sessions', 'pop');
Expand Down Expand Up @@ -167,13 +217,36 @@ const AppContent: React.FC = () => {
setTimeout(() => setActiveSessionId(null), NAV_DURATION);
}, [navigateTo]);

const handleDisconnect = useCallback(() => {
clientRef.current = null;
sessionMgrRef.current = null;
setSessionMgr(null);
setIsReconnecting(false);
setActiveSessionId(null);
setActiveSessionName('Session');
setChatAutoFocus(false);
setPrevPage(null);
setNavDir(null);
clearTimeout(timerRef.current);
localStorage.removeItem('bitfun.mobile.user_id');
useMobileStore.getState().resetConnectionState();
pageStackRef.current = ['pairing'];
setPage('pairing');
}, []);

const isAnimating = navDir !== null;
const currentPage: Page = page;

const shouldShow = (p: Page) => currentPage === p || (isAnimating && prevPage === p);

return (
<div className="mobile-app">
{isReconnecting && (
<div className="mobile-reconnect-banner">
<span className="mobile-reconnect-spinner" />
{t('sessions.reconnecting')}
</div>
)}
{page === 'pairing' && <PairingPage onPaired={handlePaired} />}
{shouldShow('workspace') && sessionMgrRef.current && (
<div className={`nav-page ${getNavClass('workspace', currentPage, navDir, isAnimating)}`}>
Expand All @@ -189,6 +262,7 @@ const AppContent: React.FC = () => {
sessionMgr={sessionMgrRef.current}
onSelectSession={handleSelectSession}
onOpenWorkspace={handleOpenWorkspace}
onDisconnect={handleDisconnect}
/>
</div>
)}
Expand All @@ -209,9 +283,11 @@ const AppContent: React.FC = () => {

const App: React.FC = () => (
<ThemeProvider>
<I18nProvider>
<AppContent />
</I18nProvider>
<ErrorBoundary>
<I18nProvider>
<AppContent />
</I18nProvider>
</ErrorBoundary>
</ThemeProvider>
);

Expand Down
75 changes: 75 additions & 0 deletions src/mobile-web/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React from 'react';

interface ErrorBoundaryProps {
children: React.ReactNode;
}

interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ErrorBoundary]', error.message, errorInfo.componentStack);
}

handleRetry = () => {
this.setState({ hasError: false, error: null });
};

render() {
if (this.state.hasError) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '32px',
textAlign: 'center',
background: 'var(--color-bg-primary)',
color: 'var(--color-text-primary)',
fontFamily: 'system-ui, sans-serif',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠</div>
<h2 style={{ fontSize: '18px', fontWeight: 600, margin: '0 0 8px' }}>
Something went wrong
</h2>
<p style={{ fontSize: '13px', color: 'var(--color-text-muted)', margin: '0 0 24px', maxWidth: '280px' }}>
{this.state.error?.message || 'An unexpected error occurred.'}
</p>
<button
onClick={this.handleRetry}
style={{
padding: '12px 32px',
border: 'none',
borderRadius: '14px',
background: 'var(--color-accent-500)',
color: '#fff',
fontSize: '15px',
fontWeight: 600,
cursor: 'pointer',
}}
>
Retry
</button>
</div>
);
}

return this.props.children;
}
}
10 changes: 9 additions & 1 deletion src/mobile-web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export const messages: Record<MobileLanguage, MessageTree> = {
deleted: 'Session deleted',
deleteFailed: 'Delete failed',
renameFailed: 'Rename failed',
disconnect: 'Disconnect',
disconnectConfirm: 'Disconnect from current desktop? You can re-pair later.',
reconnecting: 'Reconnecting...',
},
workspace: {
title: 'Workspace',
Expand Down Expand Up @@ -276,6 +279,9 @@ export const messages: Record<MobileLanguage, MessageTree> = {
deleted: '会话已删除',
deleteFailed: '删除失败',
renameFailed: '重命名失败',
disconnect: '断开连接',
disconnectConfirm: '断开当前桌面连接?之后可重新配对。',
reconnecting: '正在重新连接...',
},
workspace: {
title: '工作区',
Expand Down Expand Up @@ -450,6 +456,9 @@ export const messages: Record<MobileLanguage, MessageTree> = {
deleted: '會話已刪除',
deleteFailed: '刪除失敗',
renameFailed: '重新命名失敗',
disconnect: '斷開連接',
disconnectConfirm: '斷開當前桌面連接?之後可重新配對。',
reconnecting: '正在重新連接...',
},
workspace: {
title: '工作區',
Expand Down Expand Up @@ -529,4 +538,3 @@ export const messages: Record<MobileLanguage, MessageTree> = {
},
}
};

54 changes: 53 additions & 1 deletion src/mobile-web/src/pages/SessionListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface SessionListPageProps {
sessionMgr: RemoteSessionManager;
onSelectSession: (sessionId: string, sessionName?: string, isNew?: boolean) => void;
onOpenWorkspace: () => void;
onDisconnect: () => void;
}

function formatTime(unixStr: string, language: string, t: (key: string, params?: Record<string, string | number>) => string): string {
Expand Down Expand Up @@ -137,7 +138,7 @@ const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => (
</svg>
);

const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectSession, onOpenWorkspace }) => {
const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectSession, onOpenWorkspace, onDisconnect }) => {
const { t, language } = useI18n();
const {
sessions,
Expand Down Expand Up @@ -177,6 +178,8 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectS
const [renaming, setRenaming] = useState(false);
const [actionToast, setActionToast] = useState<string | null>(null);

const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);

const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>();
const longPressPosRef = useRef({ x: 0, y: 0 });
const longPressTriggeredRef = useRef(false);
Expand Down Expand Up @@ -576,6 +579,13 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectS
<button className="session-list__theme-btn" onClick={toggleTheme} aria-label={t('common.toggleTheme')}>
<ThemeToggleIcon isDark={isDark} />
</button>
<button className="session-list__disconnect-btn" onClick={() => setShowDisconnectConfirm(true)} aria-label={t('sessions.disconnect')} title={t('sessions.disconnect')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
</div>

Expand Down Expand Up @@ -1003,6 +1013,48 @@ const SessionListPage: React.FC<SessionListPageProps> = ({ sessionMgr, onSelectS
</div>
)}

{/* Disconnect Confirmation */}
{showDisconnectConfirm && (
<div
className="session-list__picker-overlay"
role="alertdialog"
aria-modal="true"
aria-labelledby="disconnect-confirm-title"
aria-describedby="disconnect-confirm-desc"
onClick={() => setShowDisconnectConfirm(false)}
onKeyDown={(e) => {
if (e.key === 'Escape') setShowDisconnectConfirm(false);
}}
>
<div className="session-list__confirm-modal" onClick={(e) => e.stopPropagation()}>
<div className="session-list__confirm-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</div>
<h3 id="disconnect-confirm-title" className="session-list__confirm-title">{t('sessions.disconnect')}</h3>
<p id="disconnect-confirm-desc" className="session-list__confirm-desc">{t('sessions.disconnectConfirm')}</p>
<div className="session-list__confirm-actions">
<button
className="session-list__confirm-btn session-list__confirm-btn--cancel"
onClick={() => setShowDisconnectConfirm(false)}
autoFocus
>
{t('common.cancel')}
</button>
<button
className="session-list__confirm-btn session-list__confirm-btn--danger"
onClick={() => { setShowDisconnectConfirm(false); onDisconnect(); }}
>
{t('sessions.disconnect')}
</button>
</div>
</div>
</div>
)}

{/* Action Toast */}
{actionToast && (
<div className="session-list__toast" role="alert" aria-live="assertive">{actionToast}</div>
Expand Down
17 changes: 17 additions & 0 deletions src/mobile-web/src/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ interface MobileStore {

error: string | null;
setError: (e: string | null) => void;

resetConnectionState: () => void;
}

export const useMobileStore = create<MobileStore>((set, get) => ({
Expand Down Expand Up @@ -133,4 +135,19 @@ export const useMobileStore = create<MobileStore>((set, get) => ({

error: null,
setError: (error) => set({ error }),

resetConnectionState: () =>
set({
connectionStatus: 'idle',
currentWorkspace: null,
currentAssistant: null,
pairedDisplayMode: null,
authenticatedUserId: null,
sessions: [],
activeSessionId: null,
messagesBySession: {},
deletedMessageIds: {},
activeTurn: null,
error: null,
}),
}));
9 changes: 9 additions & 0 deletions src/mobile-web/src/styles/components/sessions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@
}
}

.session-list__disconnect-btn {
@extend .session-list__theme-btn;

&:active {
color: var(--color-error);
background: var(--color-error-bg);
}
}

.session-list__workspace-bar {
position: relative;
z-index: 1;
Expand Down
Loading
Loading