From 6fd2f0108a08104bb141e7d9f43bd630e34064f0 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 1 Sep 2026 20:17:02 +0800 Subject: [PATCH] feat(mobile-web): align responsive remote experience --- pnpm-lock.yaml | 18 + src/mobile-web/package.json | 2 + src/mobile-web/src/App.tsx | 249 +- .../src/components/QrScannerSheet.tsx | 168 ++ .../src/components/RemoteHomePanel.tsx | 66 + src/mobile-web/src/hooks/useWideLayout.ts | 27 + src/mobile-web/src/i18n/messages.ts | 203 +- src/mobile-web/src/pages/ChatPage.tsx | 298 ++- src/mobile-web/src/pages/DevicesPage.tsx | 6 +- src/mobile-web/src/pages/PairingPage.tsx | 426 ++- src/mobile-web/src/pages/SessionListPage.tsx | 643 ++++- src/mobile-web/src/pages/WorkspacePage.tsx | 138 +- .../src/services/CloudAccountClient.ts | 212 ++ src/mobile-web/src/services/E2EEncryption.ts | 14 +- .../src/services/RelayHttpClient.ts | 68 + src/mobile-web/src/services/pairingLink.ts | 48 + .../src/styles/components/adaptive-shell.scss | 697 +++++ .../src/styles/components/chat-input.scss | 42 +- .../src/styles/components/chat.scss | 31 + .../src/styles/components/harmony-native.scss | 2382 +++++++++++++++++ .../src/styles/components/pairing.scss | 422 ++- src/mobile-web/src/styles/index.scss | 2 + src/mobile-web/vite.config.ts | 10 + 23 files changed, 5687 insertions(+), 485 deletions(-) create mode 100644 src/mobile-web/src/components/QrScannerSheet.tsx create mode 100644 src/mobile-web/src/components/RemoteHomePanel.tsx create mode 100644 src/mobile-web/src/hooks/useWideLayout.ts create mode 100644 src/mobile-web/src/services/CloudAccountClient.ts create mode 100644 src/mobile-web/src/services/pairingLink.ts create mode 100644 src/mobile-web/src/styles/components/adaptive-shell.scss create mode 100644 src/mobile-web/src/styles/components/harmony-native.scss diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f89834c77f..a0065f26e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,12 @@ importers: '@noble/curves': specifier: ^2.0.1 version: 2.0.1 + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 + qr-scanner: + specifier: ^1.4.2 + version: 1.4.2 react: specifier: ^18.3.1 version: 18.3.1 @@ -2169,6 +2175,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/path-browserify@1.0.3': resolution: {integrity: sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw==} @@ -4754,6 +4763,9 @@ packages: resolution: {integrity: sha512-ArbnyA3U5SGHokEvkfWjW+O8hOxV1RSJxOgriX/3A4xZRqixt9ZFHD0yPgZQF05Qj0oAqi8H/7stDorjoHY90Q==} engines: {node: '>=16.13.2'} + qr-scanner@1.4.2: + resolution: {integrity: sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==} + qrcode.react@4.2.0: resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} peerDependencies: @@ -7301,6 +7313,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/offscreencanvas@2019.7.3': {} + '@types/path-browserify@1.0.3': {} '@types/prismjs@1.26.5': {} @@ -10693,6 +10707,10 @@ snapshots: - supports-color - utf-8-validate + qr-scanner@1.4.2: + dependencies: + '@types/offscreencanvas': 2019.7.3 + qrcode.react@4.2.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index f653934b7a..445439cb50 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -17,10 +17,12 @@ "@bitfun/theme-bitfun": "workspace:^", "@noble/ciphers": "^2.1.1", "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.6", + "qr-scanner": "^1.4.2", "remark-gfm": "^4.0.1", "zustand": "^5.0.10" }, diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index 1af17e1e81..a15852b013 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -10,7 +10,9 @@ import { RemoteSessionManager } from './services/RemoteSessionManager'; import { reconcileDelegatedAccountOwner } from './services/delegatedAccountOwner'; import { ThemeProvider } from './theme'; import { useConnectionHealth } from './hooks/useConnectionHealth'; +import { useWideLayout } from './hooks/useWideLayout'; import { useMobileStore } from './services/store'; +import RemoteHomePanel from './components/RemoteHomePanel'; import './styles/index.scss'; type Page = 'pairing' | 'workspace' | 'sessions' | 'chat' | 'devices'; @@ -39,6 +41,9 @@ const AppContent: React.FC = () => { const [activeSessionId, setActiveSessionId] = useState(null); const [activeSessionName, setActiveSessionName] = useState('Session'); const [chatAutoFocus, setChatAutoFocus] = useState(false); + const [homeConversationStarting, setHomeConversationStarting] = useState(false); + const [compactSidebarOpen, setCompactSidebarOpen] = useState(false); + const isWideLayout = useWideLayout(); const connectionHealth = useMobileStore((state) => state.connectionHealth); const clientRef = useRef(null); const delegatedOwnerUnlistenRef = useRef<(() => void) | null>(null); @@ -136,6 +141,7 @@ const AppContent: React.FC = () => { pageStackRef.current = ['pairing', 'sessions']; history.pushState({ page: 'sessions' }, ''); setPage('sessions'); + setCompactSidebarOpen(false); }, [], ); @@ -196,14 +202,89 @@ const AppContent: React.FC = () => { setActiveSessionId(sessionId); setActiveSessionName(sessionName || 'Session'); setChatAutoFocus(!!isNew); + setCompactSidebarOpen(false); + if (isWideLayout) { + clearTimeout(timerRef.current); + setPrevPage(null); + setNavDir(null); + if (page !== 'chat') { + pageStackRef.current = [...pageStackRef.current.filter((entry) => entry !== 'chat'), 'chat']; + history.pushState({ page: 'chat' }, ''); + } + setPage('chat'); + return; + } navigateTo('chat', 'push'); - }, [navigateTo]); + }, [isWideLayout, navigateTo, page]); + + const handleStartConversation = useCallback(async () => { + const activeManager = sessionMgrRef.current; + if (!activeManager || homeConversationStarting) return; + const targetEpoch = activeManager.controlTargetEpoch; + const state = useMobileStore.getState(); + const assistantMode = !!state.currentAssistant && !state.currentWorkspace; + const workspacePath = assistantMode + ? state.currentAssistant?.path + : state.currentWorkspace?.path; + const identity = assistantMode + ? undefined + : { + remoteConnectionId: state.currentWorkspace?.remote_connection_id, + remoteSshHost: state.currentWorkspace?.remote_ssh_host, + }; + const agentType = assistantMode ? 'claw' : 'code'; + + setHomeConversationStarting(true); + useMobileStore.getState().setError(null); + try { + const sessionId = await activeManager.createSession( + agentType, + undefined, + workspacePath, + identity, + ); + if ( + sessionMgrRef.current !== activeManager + || activeManager.controlTargetEpoch !== targetEpoch + ) return; + handleSelectSession( + sessionId, + assistantMode ? t('sessions.remoteClawSession') : t('sessions.remoteCodeSession'), + true, + ); + } catch (error: unknown) { + if ( + sessionMgrRef.current === activeManager + && activeManager.controlTargetEpoch === targetEpoch + ) { + useMobileStore.getState().setError( + String((error as { message?: string })?.message || error), + ); + } + } finally { + setHomeConversationStarting(false); + } + }, [handleSelectSession, homeConversationStarting, t]); const handleBackToSessions = useCallback(() => { navigateTo('sessions', 'pop'); setTimeout(() => setActiveSessionId(null), NAV_DURATION); }, [navigateTo]); + const handleControlTargetChanged = useCallback(() => { + clearTimeout(timerRef.current); + setActiveSessionId(null); + setActiveSessionName('Session'); + setChatAutoFocus(false); + setHomeConversationStarting(false); + setPrevPage(null); + setNavDir(null); + pageStackRef.current = ['pairing', 'sessions']; + history.replaceState({ page: 'sessions' }, ''); + setPage('sessions'); + setCompactSidebarOpen(true); + }, []); + const handleDisconnect = useCallback(() => { delegatedOwnerUnlistenRef.current?.(); delegatedOwnerUnlistenRef.current = null; @@ -214,6 +295,8 @@ const AppContent: React.FC = () => { setActiveSessionId(null); setActiveSessionName('Session'); setChatAutoFocus(false); + setHomeConversationStarting(false); + setCompactSidebarOpen(false); setPrevPage(null); setNavDir(null); clearTimeout(timerRef.current); @@ -233,8 +316,58 @@ const AppContent: React.FC = () => { const shouldShow = (p: Page) => currentPage === p || (isAnimating && prevPage === p); + const renderSessionList = () => sessionMgrRef.current && ( + navigateTo('devices', 'push')} + onControlTargetChanged={handleControlTargetChanged} + /> + ); + + const renderDetailPage = () => { + if (currentPage === 'workspace' && sessionMgrRef.current) { + return ( + + ); + } + if (currentPage === 'devices' && clientRef.current) { + return ; + } + if (currentPage === 'chat' && sessionMgrRef.current && activeSessionId) { + return ( + + ); + } + return ( + + ); + }; + return ( -
+
{connectionHealth === 'unreachable' && page !== 'pairing' && (
@@ -245,45 +378,83 @@ const AppContent: React.FC = () => {
)} {page === 'pairing' && } - {shouldShow('workspace') && sessionMgrRef.current && ( -
- + {page !== 'pairing' && isWideLayout && sessionMgrRef.current && ( +
+ +
+ {renderDetailPage()} +
)} - {shouldShow('devices') && clientRef.current && ( -
- -
- )} - {shouldShow('sessions') && sessionMgrRef.current && ( -
- navigateTo('devices', 'push')} - /> -
- )} - {shouldShow('chat') && sessionMgrRef.current && activeSessionId && ( -
- -
+ {!isWideLayout && ( + <> + {shouldShow('workspace') && sessionMgrRef.current && ( +
+ +
+ )} + {shouldShow('devices') && clientRef.current && ( +
+ +
+ )} + {(shouldShow('sessions') || shouldShow('chat')) && sessionMgrRef.current && ( +
+ +
+ )} + )}
); diff --git a/src/mobile-web/src/components/QrScannerSheet.tsx b/src/mobile-web/src/components/QrScannerSheet.tsx new file mode 100644 index 0000000000..2e0009ebcb --- /dev/null +++ b/src/mobile-web/src/components/QrScannerSheet.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useI18n } from '../i18n'; +import { parseScannedPairingLink } from '../services/pairingLink'; + +interface QrScannerSheetProps { + onClose: () => void; + onDetected: (url: string) => void; +} + +type ScannerController = { + start: () => Promise; + stop: () => void; + destroy: () => void; +}; + +function errorName(error: unknown): string { + if (error instanceof DOMException) return error.name; + return String((error as { name?: string })?.name || ''); +} + +const QrScannerSheet: React.FC = ({ onClose, onDetected }) => { + const { t } = useI18n(); + const videoRef = useRef(null); + const scannerRef = useRef(null); + const completedRef = useRef(false); + const [starting, setStarting] = useState(true); + const [scanningImage, setScanningImage] = useState(false); + const [manualLink, setManualLink] = useState(''); + const [scanError, setScanError] = useState(null); + + const acceptValue = useCallback((rawValue: string) => { + if (completedRef.current) return; + const pairingUrl = parseScannedPairingLink(rawValue); + if (!pairingUrl) { + setScanError(t('pairing.invalidScannedCode')); + return; + } + completedRef.current = true; + scannerRef.current?.stop(); + onDetected(pairingUrl); + }, [onDetected, t]); + + useEffect(() => { + let disposed = false; + const startScanner = async () => { + try { + const { default: QrScanner } = await import('qr-scanner'); + if (disposed || !videoRef.current) return; + if (!(await QrScanner.hasCamera())) { + setScanError(t('pairing.cameraUnavailable')); + setStarting(false); + return; + } + const scanner = new QrScanner( + videoRef.current, + (result) => acceptValue(typeof result === 'string' ? result : result.data), + { + preferredCamera: 'environment', + returnDetailedScanResult: true, + highlightScanRegion: false, + highlightCodeOutline: false, + maxScansPerSecond: 8, + }, + ); + scannerRef.current = scanner; + await scanner.start(); + if (!disposed) setStarting(false); + } catch (error: unknown) { + if (disposed) return; + const name = errorName(error); + setScanError( + name === 'NotAllowedError' || name === 'PermissionDeniedError' + ? t('pairing.cameraPermissionDenied') + : t('pairing.cameraUnavailable'), + ); + setStarting(false); + } + }; + void startScanner(); + return () => { + disposed = true; + scannerRef.current?.stop(); + scannerRef.current?.destroy(); + scannerRef.current = null; + }; + }, [acceptValue, t]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const handleImage = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + setScanningImage(true); + setScanError(null); + try { + const { default: QrScanner } = await import('qr-scanner'); + const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true }); + acceptValue(typeof result === 'string' ? result : result.data); + } catch { + setScanError(t('pairing.invalidScannedCode')); + } finally { + setScanningImage(false); + } + }; + + return ( +
+
+
+
+

{t('pairing.scanTitle')}

+

{t('pairing.scanDescription')}

+
+ +
+ +
+
+
+ + {scanError &&
{scanError}
} + + + +
+ +
+ setManualLink(event.target.value)} + placeholder={t('pairing.connectionLinkPlaceholder')} + autoCapitalize="off" + autoCorrect="off" + /> + +
+
+
+
+
+ ); +}; + +export default QrScannerSheet; diff --git a/src/mobile-web/src/components/RemoteHomePanel.tsx b/src/mobile-web/src/components/RemoteHomePanel.tsx new file mode 100644 index 0000000000..7d2774431d --- /dev/null +++ b/src/mobile-web/src/components/RemoteHomePanel.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useI18n } from '../i18n'; + +interface RemoteHomePanelProps { + onOpenSidebar?: () => void; + onOpenWorkspace?: () => void; + onStartConversation?: () => void; + conversationStarting?: boolean; +} + +const RemoteHomePanel: React.FC = ({ + onOpenSidebar, + onOpenWorkspace, + onStartConversation, + conversationStarting = false, +}) => { + const { t } = useI18n(); + const openNavigation = onOpenSidebar; + + return ( +
+
+ {openNavigation ? ( + + ) : ( +
+
+

{t('shell.selectConversation')}

+
+
+ +
+
+ ); +}; + +export default RemoteHomePanel; diff --git a/src/mobile-web/src/hooks/useWideLayout.ts b/src/mobile-web/src/hooks/useWideLayout.ts new file mode 100644 index 0000000000..c9e103aee2 --- /dev/null +++ b/src/mobile-web/src/hooks/useWideLayout.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +/** + * Keep the browser breakpoint aligned with the CSS master-detail breakpoint. + * A detail pane needs comfortable room next to the 360px navigation pane. + * Keep phones, tablets, and narrow browser windows on the drawer layout; only + * desktop-sized viewports switch to persistent master-detail navigation. + */ +export const WIDE_LAYOUT_QUERY = '(min-width: 900px)'; + +function readWideLayout(): boolean { + return typeof window !== 'undefined' && window.matchMedia(WIDE_LAYOUT_QUERY).matches; +} + +export function useWideLayout(): boolean { + const [isWide, setIsWide] = useState(readWideLayout); + + useEffect(() => { + const query = window.matchMedia(WIDE_LAYOUT_QUERY); + const update = (event: MediaQueryListEvent | MediaQueryList) => setIsWide(event.matches); + update(query); + query.addEventListener('change', update); + return () => query.removeEventListener('change', update); + }, []); + + return isWide; +} diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 9543b5de76..ed1d088a99 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -11,6 +11,8 @@ export const messages: Record = { shared: SHARED_TERMS_BY_LOCALE['en-US'], common: { back: 'Back', + more: 'More', + close: 'Close', continue: 'Continue', cancel: 'Cancel', switch: 'Switch', @@ -32,6 +34,16 @@ export const messages: Record = { daysAgo: '{count}d ago', }, pairing: { + loginTitle: 'Sign in to BitFun', + connectTitle: 'Connect to BitFun', + loginDescription: 'Sign in to sync devices, sessions, and remote control state.', + advancedOptions: 'Advanced options', + loginServer: 'Login server', + relayUrlPlaceholder: 'https://example.com/relay', + secureRemote: 'Secure remote', + heroTitle: 'Your desktop work, within reach', + heroDescription: 'Continue sessions, follow progress, and respond to BitFun from any screen.', + encryptedConnection: 'End-to-end encrypted connection', enterUserIdToContinue: 'Enter your user ID to continue', enterAccountToContinue: 'Enter your BitFun account to continue', connectingAndPairing: 'Connecting and pairing...', @@ -55,11 +67,27 @@ export const messages: Record = { placeholder: 'Enter a user ID', usernamePlaceholder: 'Enter your username', passwordPlaceholder: 'Enter your password', + showPassword: 'Show password', + hidePassword: 'Hide password', note: 'The first successful connection binds this URL to your user ID for the current remote session.', accountNote: 'Use the same BitFun account currently logged in on the scanned desktop. Your password is not saved on this phone.', connecting: 'Connecting...', retryIn: 'Retry in {seconds}s', + loginAction: 'Sign in', continue: 'Continue', + scanAction: 'Scan to connect a computer', + scanTitle: 'Scan the desktop QR code', + scanDescription: 'Open Remote Control in BitFun Desktop, then place its QR code inside the frame.', + cameraPreview: 'QR scanner camera preview', + scannerStarting: 'Starting camera...', + cameraPermissionDenied: 'Camera access is required to scan. Allow access in browser settings, or use an image or connection link.', + cameraUnavailable: 'The camera is unavailable. Use a QR image or paste the connection link instead.', + invalidScannedCode: 'This is not a valid BitFun pairing QR code or connection link.', + scanFromImage: 'Choose a QR code image', + scanningImage: 'Reading image...', + pasteLink: 'Or paste a connection link', + connectionLinkPlaceholder: 'https://…/#/pair?room=…', + connectScannedLink: 'Connect', }, sessions: { switchWorkspace: 'Switch workspace', @@ -110,6 +138,37 @@ export const messages: Record = { connectionUnreachable: 'Connection lost', continueSession: 'Continue where you left off', }, + shell: { + remoteTitle: 'Remote', + selectConversation: 'Choose a conversation', + recentConversations: 'Recent conversations', + moreDevices: '{count} more devices', + moreConversations: '{count} more conversations', + moreWorkspaces: '{count} more workspaces', + askBitFun: 'Ask BitFun', + newChat: 'Chat', + continueDesktopWork: 'Continue work from your desktop', + chooseConversation: 'Choose a conversation on the left, or start a new one to keep working from this browser.', + newConversation: 'New conversation', + connectedTo: 'Connected to {name}', + workspaceReady: 'Workspace ready', + }, + settings: { + accountSection: 'Account', + currentAccount: 'Current account', + signedIn: 'Verified', + generalSection: 'General', + appearance: 'Appearance', + language: 'Language', + modelSection: 'Model', + defaultModel: 'Default model', + followDesktop: 'Follow desktop', + devicesSection: 'Devices', + currentDevice: 'Current', + aboutSection: 'About', + product: 'BitFun Remote', + platform: 'Mobile Web', + }, workspace: { loadingInfo: 'Loading workspace info...', currentWorkspace: 'Current Workspace', @@ -129,16 +188,16 @@ export const messages: Record = { hideResponse: 'Hide response', analyzingImage: 'Analyzing image with image understanding model...', inputPlaceholder: 'How can I help you...', - collapsedInputPlaceholder: 'Ask BitFun...', + collapsedInputPlaceholder: 'Ask BitFun', collapsedStreamingPlaceholder: 'Replying...', messageQueued: 'Message queued; it will run after the current step', imageAnalyzingPlaceholder: 'Analyzing image...', imageAttachmentFallback: '(see attached images)', modelSelection: 'Select model', + modelAuto: 'Auto', + modelAutoDesc: 'Automatically route to the best model', modelPrimary: 'Primary Model', - modelPrimaryDesc: 'Uses the configured primary model', modelFast: 'Fast Model', - modelFastDesc: 'Uses the configured fast model', reasoningSelection: 'Select reasoning effort', reasoningAuto: 'Auto', askQuestionCount: '{count} question{suffix}', @@ -199,6 +258,8 @@ export const messages: Record = { shared: SHARED_TERMS_BY_LOCALE['zh-CN'], common: { back: '返回', + more: '更多', + close: '关闭', continue: '继续', cancel: '取消', switch: '切换', @@ -220,6 +281,16 @@ export const messages: Record = { daysAgo: '{count} 天前', }, pairing: { + loginTitle: '登录 BitFun', + connectTitle: '连接 BitFun', + loginDescription: '登录后即可同步设备、会话和远程控制状态。', + advancedOptions: '高级选项', + loginServer: '登录服务器', + relayUrlPlaceholder: 'https://example.com/relay', + secureRemote: '安全远控', + heroTitle: '电脑上的工作,随时继续', + heroDescription: '在任意屏幕上继续会话、查看进度,并及时回复 BitFun。', + encryptedConnection: '端到端加密连接', enterUserIdToContinue: '请输入你的用户 ID 继续', enterAccountToContinue: '请输入你的 BitFun 账号继续', connectingAndPairing: '正在连接并配对...', @@ -241,13 +312,29 @@ export const messages: Record = { usernameLabel: '用户名', passwordLabel: '密码', placeholder: '请输入用户 ID', - usernamePlaceholder: '请输入用户名', - passwordPlaceholder: '请输入密码', + usernamePlaceholder: 'BitFun 用户名', + passwordPlaceholder: 'BitFun 密码', + showPassword: '显示密码', + hidePassword: '隐藏密码', note: '首次成功连接后,本次远程会话会把该 URL 绑定到你的用户 ID。', accountNote: '请使用被扫码桌面端当前登录的 BitFun 账号。密码不会保存在本机。', connecting: '连接中...', retryIn: '{seconds} 秒后重试', + loginAction: '登录', continue: '继续', + scanAction: '扫码连接电脑', + scanTitle: '扫描桌面端二维码', + scanDescription: '在 BitFun 桌面端打开远程控制,将二维码放入取景框内。', + cameraPreview: '二维码扫描相机画面', + scannerStarting: '正在启动相机...', + cameraPermissionDenied: '扫码需要相机权限,请在浏览器设置中允许访问,或改用二维码图片、连接链接。', + cameraUnavailable: '无法使用相机,请选择二维码图片或粘贴连接链接。', + invalidScannedCode: '这不是有效的 BitFun 配对二维码或连接链接。', + scanFromImage: '选择二维码图片', + scanningImage: '正在识别图片...', + pasteLink: '或粘贴连接链接', + connectionLinkPlaceholder: 'https://…/#/pair?room=…', + connectScannedLink: '连接', }, sessions: { switchWorkspace: '切换工作区', @@ -298,6 +385,37 @@ export const messages: Record = { connectionUnreachable: '连接断开', continueSession: '继续上次会话', }, + shell: { + remoteTitle: '远程', + selectConversation: '选择一个会话', + recentConversations: '最近对话', + moreDevices: '还有 {count} 台设备', + moreConversations: '还有 {count} 个对话', + moreWorkspaces: '还有 {count} 个工作区', + askBitFun: '向 BitFun 提问', + newChat: '聊天', + continueDesktopWork: '继续电脑上的工作', + chooseConversation: '从侧边栏打开会话,或新建一个。', + newConversation: '新建会话', + connectedTo: '已连接到 {name}', + workspaceReady: '工作区已就绪', + }, + settings: { + accountSection: '账号', + currentAccount: '当前账号', + signedIn: '已认证', + generalSection: '通用', + appearance: '外观', + language: '语言', + modelSection: '模型', + defaultModel: '默认模型', + followDesktop: '跟随桌面端', + devicesSection: '设备', + currentDevice: '当前设备', + aboutSection: '关于', + product: 'BitFun Remote', + platform: '移动网页', + }, workspace: { loadingInfo: '正在加载工作区信息...', currentWorkspace: '当前工作区', @@ -317,16 +435,16 @@ export const messages: Record = { hideResponse: 'Hide response', analyzingImage: '正在使用图像理解模型分析图片...', inputPlaceholder: '有什么可以帮您...', - collapsedInputPlaceholder: '发消息...', + collapsedInputPlaceholder: '向 BitFun 提问', collapsedStreamingPlaceholder: '正在回复...', messageQueued: '已加入队列,将在当前步骤结束后处理', imageAnalyzingPlaceholder: '正在分析图片...', imageAttachmentFallback: '(见附带图片)', modelSelection: '选择模型', + modelAuto: 'Auto', + modelAutoDesc: '自动路由到最合适的模型', modelPrimary: 'Primary 模型', - modelPrimaryDesc: '使用已配置的主力模型', modelFast: 'Fast 模型', - modelFastDesc: '使用已配置的快速模型', reasoningSelection: '选择思考强度', reasoningAuto: 'Auto', askQuestionCount: '{count} 个问题{suffix}', @@ -387,6 +505,8 @@ export const messages: Record = { shared: SHARED_TERMS_BY_LOCALE['zh-TW'], common: { back: '返回', + more: '更多', + close: '關閉', continue: '繼續', cancel: '取消', switch: '切換', @@ -408,6 +528,16 @@ export const messages: Record = { daysAgo: '{count} 天前', }, pairing: { + loginTitle: '登入 BitFun', + connectTitle: '連接 BitFun', + loginDescription: '登入後即可同步裝置、會話和遠端控制狀態。', + advancedOptions: '進階選項', + loginServer: '登入伺服器', + relayUrlPlaceholder: 'https://example.com/relay', + secureRemote: '安全遠控', + heroTitle: '電腦上的工作,隨時繼續', + heroDescription: '在任意螢幕上繼續對話、查看進度,並及時回覆 BitFun。', + encryptedConnection: '端對端加密連線', enterUserIdToContinue: '請輸入你的用戶 ID 繼續', enterAccountToContinue: '請輸入你的 BitFun 帳號繼續', connectingAndPairing: '正在連接並配對...', @@ -429,13 +559,29 @@ export const messages: Record = { usernameLabel: '用戶名', passwordLabel: '密碼', placeholder: '請輸入用戶 ID', - usernamePlaceholder: '請輸入用戶名', - passwordPlaceholder: '請輸入密碼', + usernamePlaceholder: 'BitFun 用戶名', + passwordPlaceholder: 'BitFun 密碼', + showPassword: '顯示密碼', + hidePassword: '隱藏密碼', note: '首次成功連接後,本次遠程會話會把該 URL 綁定到你的用戶 ID。', accountNote: '請使用被掃碼桌面端目前登入的 BitFun 帳號。密碼不會保存在本機。', connecting: '連接中...', retryIn: '{seconds} 秒後重試', + loginAction: '登入', continue: '繼續', + scanAction: '掃碼連接電腦', + scanTitle: '掃描桌面端 QR Code', + scanDescription: '在 BitFun 桌面端開啟遠端控制,將 QR Code 放入取景框內。', + cameraPreview: 'QR Code 掃描相機畫面', + scannerStarting: '正在啟動相機...', + cameraPermissionDenied: '掃碼需要相機權限,請在瀏覽器設定中允許存取,或改用 QR Code 圖片、連接連結。', + cameraUnavailable: '無法使用相機,請選擇 QR Code 圖片或貼上連接連結。', + invalidScannedCode: '這不是有效的 BitFun 配對 QR Code 或連接連結。', + scanFromImage: '選擇 QR Code 圖片', + scanningImage: '正在辨識圖片...', + pasteLink: '或貼上連接連結', + connectionLinkPlaceholder: 'https://…/#/pair?room=…', + connectScannedLink: '連接', }, sessions: { switchWorkspace: '切換工作區', @@ -486,6 +632,37 @@ export const messages: Record = { connectionUnreachable: '連接斷開', continueSession: '繼續上次會話', }, + shell: { + remoteTitle: '遠程', + selectConversation: '選擇一個對話', + recentConversations: '最近對話', + moreDevices: '還有 {count} 台裝置', + moreConversations: '還有 {count} 個對話', + moreWorkspaces: '還有 {count} 個工作區', + askBitFun: '向 BitFun 提問', + newChat: '聊天', + continueDesktopWork: '繼續電腦上的工作', + chooseConversation: '從側邊欄開啟對話,或建立一個。', + newConversation: '建立對話', + connectedTo: '已連接到 {name}', + workspaceReady: '工作區已就緒', + }, + settings: { + accountSection: '帳號', + currentAccount: '目前帳號', + signedIn: '已認證', + generalSection: '一般', + appearance: '外觀', + language: '語言', + modelSection: '模型', + defaultModel: '預設模型', + followDesktop: '跟隨桌面端', + devicesSection: '裝置', + currentDevice: '目前裝置', + aboutSection: '關於', + product: 'BitFun Remote', + platform: '行動網頁', + }, workspace: { loadingInfo: '正在加載工作區信息...', currentWorkspace: '當前工作區', @@ -505,16 +682,16 @@ export const messages: Record = { hideResponse: 'Hide response', analyzingImage: '正在使用圖像理解模型分析圖片...', inputPlaceholder: '有什麼可以幫您...', - collapsedInputPlaceholder: '發消息...', + collapsedInputPlaceholder: '向 BitFun 提問', collapsedStreamingPlaceholder: '正在回覆...', messageQueued: '已加入隊列,將在當前步驟結束後處理', imageAnalyzingPlaceholder: '正在分析圖片...', imageAttachmentFallback: '(見附帶圖片)', modelSelection: '選擇模型', + modelAuto: 'Auto', + modelAutoDesc: '自動路由到最合適的模型', modelPrimary: 'Primary 模型', - modelPrimaryDesc: '使用已設定的主力模型', modelFast: 'Fast 模型', - modelFastDesc: '使用已設定的快速模型', reasoningSelection: '選擇思考強度', reasoningAuto: 'Auto', askQuestionCount: '{count} 個問題{suffix}', diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index d35f09e8f8..9308ee1284 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -100,6 +100,7 @@ interface ChatPageProps { sessionName?: string; onBack: () => void; autoFocus?: boolean; + wideLayout?: boolean; } // ─── Markdown ─────────────────────────────────────────────────────────────── @@ -1704,18 +1705,6 @@ function renderActiveTurnItems( ); } -// ─── Theme toggle icon ───────────────────────────────────────────────────── - -const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => ( - - {isDark ? ( - - ) : ( - - )} - -); - const SparklesIcon: React.FC<{ className?: string; size?: number }> = ({ className, size = 10 }) => ( = ({ classNa ); +type ModelSelectionValue = 'auto' | 'primary' | 'fast' | string; + function formatProviderName(provider: string): string { const normalized = provider.trim(); if (!normalized) return 'Unknown'; @@ -1763,12 +1754,14 @@ function normalizeSelectedModelId( catalog: RemoteModelCatalog | null, ): string { const value = selectedModelId?.trim(); - if (!value || value === 'primary') return 'primary'; - if (value === 'fast') { - const defaultId = catalog?.default_models?.fast; - return defaultId && resolveModelSelection(defaultId, catalog) ? value : 'primary'; + if (!value || value === 'auto' || value === 'default') return 'auto'; + if (value === 'primary' || value === 'fast') { + const defaultId = value === 'primary' + ? catalog?.default_models?.primary + : catalog?.default_models?.fast; + return defaultId && resolveModelSelection(defaultId, catalog) ? value : 'auto'; } - return resolveModelSelection(value, catalog) ? value : 'primary'; + return resolveModelSelection(value, catalog) ? value : 'auto'; } function loadLastSelectedModelId(): string | null { @@ -1790,16 +1783,17 @@ function persistLastSelectedModelId(modelId: string): void { function resolvePreferredModelSelection( preferredModelId: string | null, catalog: RemoteModelCatalog | null, -): { modelId: string | null; fallbackApplied: boolean } { +): { modelId: string | null; fellBackToAuto: boolean } { const value = preferredModelId?.trim(); if (!value) { - return { modelId: null, fallbackApplied: false }; + return { modelId: null, fellBackToAuto: false }; } const normalizedModelId = normalizeSelectedModelId(value, catalog); + const fellBackToAuto = normalizedModelId === 'auto' && value !== 'auto' && value !== 'default'; return { modelId: normalizedModelId, - fallbackApplied: normalizedModelId !== value, + fellBackToAuto, }; } @@ -1816,7 +1810,7 @@ function resolveConcreteModelSelection( catalog: RemoteModelCatalog | null, ): RemoteModelConfig | null { const normalizedModelId = normalizeSelectedModelId(modelId, catalog); - if (normalizedModelId === 'primary') { + if (normalizedModelId === 'auto' || normalizedModelId === 'primary') { return resolveModelSelection(catalog?.default_models?.primary || '', catalog); } if (normalizedModelId === 'fast') { @@ -1859,12 +1853,23 @@ function getSelectedModelInfo( enableThinking: boolean; reasoningEffort?: string; } { + if (selectedModelId === 'auto') { + const resolved = resolveConcreteModelSelection(selectedModelId, catalog); + return { + label: t('chat.modelAuto'), + meta: t('chat.modelAutoDesc'), + enableThinking: resolved?.reasoning?.status === 'known', + reasoningEffort: selectedReasoningLabel(resolved, catalog), + }; + } + if (selectedModelId === 'primary' || selectedModelId === 'fast') { const resolved = resolveConcreteModelSelection(selectedModelId, catalog); return { - label: selectedModelId === 'primary' ? t('chat.modelPrimary') : t('chat.modelFast'), - meta: buildModelProviderMeta(resolved) - || t(selectedModelId === 'primary' ? 'chat.modelPrimaryDesc' : 'chat.modelFastDesc'), + label: resolved + ? (selectedModelId === 'primary' ? t('chat.modelPrimary') : t('chat.modelFast')) + : t('chat.modelAuto'), + meta: buildModelProviderMeta(resolved) || t('chat.modelAutoDesc'), enableThinking: resolved?.reasoning?.status === 'known', reasoningEffort: selectedReasoningLabel(resolved, catalog), }; @@ -1873,8 +1878,8 @@ function getSelectedModelInfo( const resolved = resolveModelSelection(selectedModelId, catalog); if (!resolved) { return { - label: t('chat.modelPrimary'), - meta: t('chat.modelPrimaryDesc'), + label: t('chat.modelAuto'), + meta: t('chat.modelAutoDesc'), enableThinking: false, }; } @@ -1972,6 +1977,16 @@ const ModelSelectorPill: React.FC<{ {open && (
{t('chat.modelSelection')}
+ + {headerMenuOpen && ( +
+ {isStreaming && ( + + )} + +
+ )}
@@ -3316,6 +3396,17 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, className="chat-page__input-box" onClick={!inputExpanded ? expandInput : undefined} > + {/* Input area */}
{inputExpanded ? ( @@ -3347,6 +3438,17 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName,
{inputExpanded && ( <> + = ({ sessionMgr, sessionId, sessionName, )}
- {inputExpanded && ( - - )} {imageAnalyzing ? ( - ) : isStreaming ? ( -
- - -
+ ) : ( )}
diff --git a/src/mobile-web/src/pages/DevicesPage.tsx b/src/mobile-web/src/pages/DevicesPage.tsx index 4915ebbcd4..3737b162ab 100644 --- a/src/mobile-web/src/pages/DevicesPage.tsx +++ b/src/mobile-web/src/pages/DevicesPage.tsx @@ -72,13 +72,15 @@ const DevicesPage: React.FC = ({ client, onBack }) => { const identityRequestRef = useRef(0); const devicesRequestRef = useRef(0); const switchRequestRef = useRef(0); - const sortedDevices = useMemo(() => [...devices].sort((left, right) => { + const sortedDevices = useMemo(() => devices.filter((device) => ( + device.device_id !== client.controllerDeviceId + )).sort((left, right) => { const leftCurrent = left.device_id === client.pairedDeviceId; const rightCurrent = right.device_id === client.pairedDeviceId; if (leftCurrent !== rightCurrent) return leftCurrent ? -1 : 1; if (left.online !== right.online) return left.online ? -1 : 1; return (left.device_name || left.device_id).localeCompare(right.device_name || right.device_id); - }), [client.pairedDeviceId, devices]); + }), [client.controllerDeviceId, client.pairedDeviceId, devices]); const friendlyError = useCallback((value: unknown, fallbackKey: string) => { const message = String((value as { message?: string })?.message || value); diff --git a/src/mobile-web/src/pages/PairingPage.tsx b/src/mobile-web/src/pages/PairingPage.tsx index 6078e2819e..3472c884d7 100644 --- a/src/mobile-web/src/pages/PairingPage.tsx +++ b/src/mobile-web/src/pages/PairingPage.tsx @@ -1,26 +1,16 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import LanguageToggleButton from '../components/LanguageToggleButton'; +import QrScannerSheet from '../components/QrScannerSheet'; import { useI18n } from '../i18n'; +import { CloudAccountClient, CloudAccountRequestError } from '../services/CloudAccountClient'; +import { normalizeRelayUrl, validPairingSecret } from '../services/pairingLink'; import { RelayHttpClient } from '../services/RelayHttpClient'; import { RemoteSessionManager } from '../services/RemoteSessionManager'; import { useMobileStore } from '../services/store'; -import { useTheme } from '../theme'; -import logoIcon from '../assets/Logo-ICON.png'; interface PairingPageProps { onPaired: (client: RelayHttpClient, sessionMgr: RemoteSessionManager) => void; } -const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => ( - - {isDark ? ( - - ) : ( - - )} - -); - const MOBILE_INSTALL_ID_KEY = 'bitfun.mobile.install_id'; const MOBILE_USER_ID_KEY = 'bitfun.mobile.user_id'; const MOBILE_LOCK_UNTIL_KEY = 'bitfun.mobile.user_id_lock_until'; @@ -37,38 +27,6 @@ function isProtectedUserIdError(message: string): boolean { || message.includes('Too many pairing attempts'); } -function normalizeRelayUrl(value: string): string | null { - try { - const normalized = value - .replace(/^wss:\/\//, 'https://') - .replace(/^ws:\/\//, 'http://') - .replace(/\/ws\/?$/, '') - .replace(/\/$/, ''); - const url = new URL(normalized); - if (!['http:', 'https:'].includes(url.protocol) - || !url.hostname - || url.username - || url.password - || url.search - || url.hash) { - return null; - } - return url.toString().replace(/\/$/, ''); - } catch { - return null; - } -} - -function validPairingSecret(room: string | null, publicKey: string | null): boolean { - return !!room - && room.length <= 128 - && /^[A-Za-z0-9_-]+$/.test(room) - && !['_store', 'page-data', 'pages'].includes(room) - && !!publicKey - && publicKey.length <= 512 - && /^[A-Za-z0-9+/=_-]+$/.test(publicKey); -} - function generateInstallId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -90,14 +48,22 @@ function resolvePairingTarget(): { httpBaseUrl: string; accountAuth: boolean; accountUsername: string | null; + directAccountLogin: boolean; } { const hash = window.location.hash; const params = new URLSearchParams(hash.replace(/^#\/pair\?/, '')); const room = params.get('room'); const pk = params.get('pk'); const relayParam = params.get('relay'); - const accountAuth = params.get('auth') === 'account'; + const authMode = params.get('auth'); + const isPairingRoute = hash === '#/pair' || hash.startsWith('#/pair?'); + // A direct visit is the account-facing product entry, so it must expose the + // same username/password form as the native mobile app. QR links from older + // Desktop builds remain legacy-compatible when they omit `auth`; they can + // also opt in explicitly with `auth=legacy`. + const accountAuth = authMode === 'account' || (!isPairingRoute && authMode !== 'legacy'); const accountUsername = params.get('user')?.trim() || null; + const directAccountLogin = accountAuth && !isPairingRoute; if (relayParam) { return { @@ -106,6 +72,7 @@ function resolvePairingTarget(): { httpBaseUrl: normalizeRelayUrl(relayParam) ?? '', accountAuth, accountUsername, + directAccountLogin, }; } @@ -116,15 +83,15 @@ function resolvePairingTarget(): { return { room, pk, - httpBaseUrl: origin + pathname, + httpBaseUrl: directAccountLogin ? `${origin}/relay` : origin + pathname, accountAuth, accountUsername, + directAccountLogin, }; } const PairingPage: React.FC = ({ onPaired }) => { const { t } = useI18n(); - const { isDark, toggleTheme } = useTheme(); const { connectionStatus, setConnectionStatus, @@ -135,12 +102,16 @@ const PairingPage: React.FC = ({ onPaired }) => { } = useMobileStore(); const [userId, setUserId] = useState(''); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [scannerOpen, setScannerOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [failureCount, setFailureCount] = useState(0); const [lockUntil, setLockUntil] = useState(null); const [now, setNow] = useState(() => Date.now()); const failureCountRef = useRef(0); const lockUntilRef = useRef(null); + const usernameInputRef = useRef(null); + const passwordInputRef = useRef(null); // Generation token so a superseded or unmounted pairing attempt cannot // overwrite UI after a later bootstrap/manual attempt owns the page. const pairAttemptGenerationRef = useRef(0); @@ -153,12 +124,31 @@ const PairingPage: React.FC = ({ onPaired }) => { onPairedRef.current = onPaired; const pairingTarget = useMemo(() => resolvePairingTarget(), []); + const [relayUrl, setRelayUrl] = useState(pairingTarget.httpBaseUrl); const requiresAccountAuth = pairingTarget.accountAuth; const isLocked = !!lockUntil && lockUntil > now; const remainingLockSeconds = isLocked ? Math.max(1, Math.ceil((lockUntil - now) / 1000)) : 0; + useEffect(() => { + // Password managers can restore values without dispatching React change + // events. Reconcile the visible controls so enabled/disabled state stays + // identical to the native login page. + const reconcileAutofill = () => { + const restoredUsername = usernameInputRef.current?.value ?? ''; + const restoredPassword = passwordInputRef.current?.value ?? ''; + if (restoredUsername && !userId) setUserId(restoredUsername); + if (restoredPassword && !password) setPassword(restoredPassword); + }; + const frame = window.requestAnimationFrame(reconcileAutofill); + const timer = window.setTimeout(reconcileAutofill, 250); + return () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(timer); + }; + }, [password, userId]); + const attemptPair = useCallback(async ( providedUserId: string, providedPassword: string, @@ -166,6 +156,7 @@ const PairingPage: React.FC = ({ onPaired }) => { ) => { const roomId = pairingTarget.room; const desktopPublicKey = pairingTarget.pk; + const httpBaseUrl = normalizeRelayUrl(relayUrl) ?? ''; const userIdValue = providedUserId.trim(); // Passwords are opaque credentials: preserve intentional leading or // trailing spaces exactly as entered. @@ -183,10 +174,10 @@ const PairingPage: React.FC = ({ onPaired }) => { const attemptGeneration = ++pairAttemptGenerationRef.current; const isCurrentAttempt = () => pairAttemptGenerationRef.current === attemptGeneration; - if (!roomId + if (!pairingTarget.directAccountLogin && (!roomId || !desktopPublicKey || !validPairingSecret(roomId, desktopPublicKey) - || !pairingTarget.httpBaseUrl) { + || !httpBaseUrl)) { if (!isCurrentAttempt()) return; setError(t('pairing.invalidQrCode')); setConnectionStatus('error'); @@ -221,10 +212,57 @@ const PairingPage: React.FC = ({ onPaired }) => { setError(null); setConnectionStatus('pairing'); - const client = new RelayHttpClient(pairingTarget.httpBaseUrl, roomId); + const client = new RelayHttpClient(httpBaseUrl, roomId ?? ''); try { - const initialSync = await client.pair(desktopPublicKey, { + if (pairingTarget.directAccountLogin) { + const accountSession = await new CloudAccountClient().login( + httpBaseUrl, + userIdValue, + passwordValue, + currentInstallId, + ); + if (!isCurrentAttempt()) { + accountSession.masterKey.fill(0); + return; + } + client.installDirectAccountIdentity({ + ...accountSession, + deviceId: currentInstallId, + }); + accountSession.masterKey.fill(0); + + const devices = await client.listDevices(); + if (!isCurrentAttempt()) return; + const remoteDevices = devices.filter((device) => ( + device.device_id !== client.controllerDeviceId + )); + const targetDevice = remoteDevices.find((device) => device.online) ?? remoteDevices[0]; + if (!targetDevice) { + throw new Error(t('devices.noDevices')); + } + client.setPairedDeviceId(targetDevice.device_id); + + const store = useMobileStore.getState(); + store.setAuthenticatedUserId(accountSession.userId); + store.setAuthenticatedUserLabel(userIdValue); + store.setControlTarget({ + deviceId: targetDevice.device_id, + deviceName: targetDevice.device_name || null, + isHome: false, + }); + setConnectionStatus('paired'); + localStorage.setItem(MOBILE_USER_ID_KEY, userIdValue); + localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); + localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); + setFailureCount(0); + setLockUntil(null); + setPassword(''); + onPairedRef.current(client, new RemoteSessionManager(client)); + return; + } + + const initialSync = await client.pair(desktopPublicKey!, { userId: userIdValue, mobileInstallId: currentInstallId, password: requiresAccountAuth ? passwordValue : undefined, @@ -324,13 +362,15 @@ const PairingPage: React.FC = ({ onPaired }) => { } catch (e: any) { if (!isCurrentAttempt()) return; const rawErrorMessage = e?.message || ''; + const status = e instanceof CloudAccountRequestError ? e.status : e?.status; const errorMessage = rawErrorMessage.includes('timed out') ? t('pairing.requestTimedOut') - : rawErrorMessage.includes('HTTP 404') + : status === 404 || rawErrorMessage.includes('HTTP 404') ? t('pairing.qrExpired') - : rawErrorMessage.includes('HTTP 429') + : status === 429 || rawErrorMessage.includes('HTTP 429') ? t('pairing.rateLimited') - : rawErrorMessage.includes('HTTP 503') || rawErrorMessage.includes('HTTP 504') + : status === 503 || status === 504 + || rawErrorMessage.includes('HTTP 503') || rawErrorMessage.includes('HTTP 504') ? t('pairing.relayUnavailable') : rawErrorMessage || t('pairing.pairingFailed'); if (!autoReconnect && isProtectedUserIdError(errorMessage)) { @@ -362,9 +402,10 @@ const PairingPage: React.FC = ({ onPaired }) => { } } }, [ - pairingTarget.httpBaseUrl, + pairingTarget.directAccountLogin, pairingTarget.pk, pairingTarget.room, + relayUrl, requiresAccountAuth, setAuthenticatedUserId, setAuthenticatedUserLabel, @@ -451,99 +492,206 @@ const PairingPage: React.FC = ({ onPaired }) => { }, [lockUntil]); const handleConnect = async () => { - await attemptPair(userId, password, { autoReconnect: false }); + await attemptPair( + usernameInputRef.current?.value ?? userId, + passwordInputRef.current?.value ?? password, + { autoReconnect: false }, + ); }; - const stateLabels: Record = { - idle: requiresAccountAuth - ? t('pairing.enterAccountToContinue') - : t('pairing.enterUserIdToContinue'), - pairing: t('pairing.connectingAndPairing'), - paired: t('pairing.pairedLoadingSessions'), - error: t('pairing.connectionError'), - }; const showSpinner = connectionStatus === 'pairing'; const showForm = connectionStatus === 'idle' || connectionStatus === 'error'; return (
-
- - -
- BitFun -
{t('shared.product.remote')}
- -
- {showSpinner &&
} -
- -
- {stateLabels[connectionStatus] || connectionStatus} -
+
+ +
+
+
+ + {showForm && ( +
{ event.preventDefault(); handleConnect(); }}> +
+
+

+ {requiresAccountAuth ? t('pairing.loginTitle') : t('pairing.connectTitle')} +

+

+ {requiresAccountAuth ? t('pairing.loginDescription') : t('pairing.note')} +

+
+ + {requiresAccountAuth && ( +
+ + + setPassword(e.target.value)} + onAnimationStart={(e) => { + if (e.animationName === 'pairingAutofillReconcile') { + setPassword(e.currentTarget.value); + } + }} + placeholder={t('pairing.passwordPlaceholder')} + autoComplete="current-password" + maxLength={1024} + disabled={submitting || isLocked} + /> + +
+ )} +
+
+ + + {t('pairing.advancedOptions')} + +
+ +
+
+ {error &&
{error}
} +
+
+
+ + +
+
+ )} - {showForm && ( -
- - {requiresAccountAuth && ( - + {!showForm && ( +
+
+ {connectionStatus === 'paired' ? t('pairing.pairedLoadingSessions') : t('pairing.connectingAndPairing')} +
)} -

- {requiresAccountAuth ? t('pairing.accountNote') : t('pairing.note')} -

- -
+
+
+ {scannerOpen && ( + setScannerOpen(false)} + onDetected={(url) => window.location.assign(url)} + /> )} - - {error &&
{error}
}
); }; diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index fb661756ec..42c431b0cc 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useLayoutEffect, useRef, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; import LanguageToggleButton from '../components/LanguageToggleButton'; import { useControlTargetEpoch } from '../hooks/useControlTargetEpoch'; import { useI18n } from '../i18n'; @@ -11,6 +12,10 @@ import { import { useMobileStore } from '../services/store'; import { useTheme } from '../theme'; import logoIcon from '../assets/Logo-ICON.png'; +import { + isDelegatedIdentityChangedError, + type RelayHttpClient, +} from '../services/RelayHttpClient'; const PAGE_SIZE = 30; @@ -23,10 +28,69 @@ type DisplayMode = 'pro' | 'assistant'; interface SessionListPageProps { sessionMgr: RemoteSessionManager; + client?: RelayHttpClient; + compact?: boolean; + activeSessionId?: string | null; onSelectSession: (sessionId: string, sessionName?: string, isNew?: boolean) => void; onOpenWorkspace: () => void; onDisconnect: () => void; onOpenDevices?: () => void; + onControlTargetChanged?: () => void; +} + +type CompactDevice = { + device_id: string; + device_name: string; + online: boolean; +}; + +type CompactWorkspaceLoadStatus = 'idle' | 'loading' | 'ready' | 'failed'; + +function compactWorkspaceKey(workspace: RecentWorkspaceEntry): string { + return `${workspace.remote_connection_id ?? 'local'}:${workspace.remote_ssh_host ?? ''}:${workspace.path}`; +} + +function mergeCompactWorkspaces( + recent: RecentWorkspaceEntry[], + currentWorkspace: { + path?: string; + project_name?: string; + workspace_kind?: 'normal' | 'assistant' | 'remote'; + remote_connection_id?: string; + remote_ssh_host?: string; + } | null, + sessions: SessionInfo[], +): RecentWorkspaceEntry[] { + const merged: RecentWorkspaceEntry[] = []; + const seen = new Set(); + const append = (workspace: RecentWorkspaceEntry) => { + if (!workspace.path) return; + const key = compactWorkspaceKey(workspace); + if (seen.has(key)) return; + seen.add(key); + merged.push(workspace); + }; + + if (currentWorkspace?.path) { + append({ + path: currentWorkspace.path, + name: currentWorkspace.project_name || currentWorkspace.path.split('/').filter(Boolean).pop() || currentWorkspace.path, + last_opened: '', + workspace_kind: currentWorkspace.workspace_kind, + remote_connection_id: currentWorkspace.remote_connection_id, + remote_ssh_host: currentWorkspace.remote_ssh_host, + }); + } + recent.forEach(append); + sessions.forEach((session) => { + if (!session.workspace_path) return; + append({ + path: session.workspace_path, + name: session.workspace_name || session.workspace_path.split('/').filter(Boolean).pop() || session.workspace_path, + last_opened: session.updated_at, + }); + }); + return merged; } type SessionListTargetOwner = { @@ -144,6 +208,29 @@ function SessionTypeIcon({ agentType }: { agentType: string }) { ); } +function CompactDeviceIcon({ name }: { name: string }) { + const normalized = name.toLocaleLowerCase(); + if (/(macbook|laptop|notebook)/.test(normalized)) { + return ( + + + + ); + } + if (/(server|ecs|cloud|host)/.test(normalized)) { + return ( + + + + ); + } + return ( + + + + ); +} + /* Mode Selection Icons */ const ProModeIcon = () => ( @@ -179,7 +266,17 @@ const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => ( ); -const SessionListPage: React.FC = ({ sessionMgr, onSelectSession, onOpenWorkspace, onDisconnect, onOpenDevices }) => { +const SessionListPage: React.FC = ({ + sessionMgr, + client, + compact = false, + activeSessionId, + onSelectSession, + onOpenWorkspace, + onDisconnect, + onOpenDevices, + onControlTargetChanged, +}) => { const renameInputCompositionActiveRef = useRef(false); const { t, formatDate } = useI18n(); const { @@ -195,6 +292,8 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS authenticatedUserLabel, connectionHealth, controlTarget, + setControlTarget, + resetForDeviceSwitch, } = useMobileStore(); const { isDark, toggleTheme } = useTheme(); const [creating, setCreating] = useState(false); @@ -232,6 +331,20 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS const [actionToast, setActionToast] = useState(null); const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); + const [compactSearchOpen, setCompactSearchOpen] = useState(false); + const [compactDevices, setCompactDevices] = useState([]); + const [compactDirectoryLoading, setCompactDirectoryLoading] = useState(false); + const [compactSelectedDeviceId, setCompactSelectedDeviceId] = useState( + () => client?.pairedDeviceId ?? null, + ); + const [compactSwitchingDeviceId, setCompactSwitchingDeviceId] = useState(null); + const [compactExpandedWorkspaces, setCompactExpandedWorkspaces] = useState>(() => new Set()); + const [compactWorkspaceSessions, setCompactWorkspaceSessions] = useState>({}); + const [compactWorkspaceStatuses, setCompactWorkspaceStatuses] = useState>({}); + const [compactVisibleSessionCounts, setCompactVisibleSessionCounts] = useState>({}); + const [compactVisibleDeviceCount, setCompactVisibleDeviceCount] = useState(3); + const [compactVisibleWorkspaceCount, setCompactVisibleWorkspaceCount] = useState(3); + const [compactSettingsOpen, setCompactSettingsOpen] = useState(false); const longPressTimerRef = useRef>(); const longPressPosRef = useRef({ x: 0, y: 0 }); @@ -422,6 +535,15 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS setActionToast(null); setShowDisconnectConfirm(false); setSearchQuery(''); + setCompactSelectedDeviceId(client?.pairedDeviceId ?? null); + setCompactSwitchingDeviceId(null); + setCompactExpandedWorkspaces(new Set()); + setCompactWorkspaceSessions({}); + setCompactWorkspaceStatuses({}); + setCompactVisibleSessionCounts({}); + setCompactVisibleDeviceCount(3); + setCompactVisibleWorkspaceCount(3); + setCompactSettingsOpen(false); setDisplayMode('pro'); setHasMore(false); setSessions([]); @@ -439,6 +561,7 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS }; }, [ controlTargetEpoch, + client, sessionMgr, setCurrentAssistant, setCurrentWorkspace, @@ -528,6 +651,207 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS } }, [captureSessionListEpoch, isSessionListCurrent, sessionMgr, setError]); + const loadCompactDirectory = useCallback(async () => { + if (!compact) return; + setCompactDirectoryLoading(true); + try { + const tasks: Promise[] = [loadWorkspaceList()]; + if (client?.hasDelegatedIdentity) { + tasks.push(client.listDevices().then((list) => { + setCompactDevices(list.filter((device) => ( + device.device_id !== client.controllerDeviceId + ))); + })); + } + await Promise.all(tasks); + } catch (e: any) { + setError(e?.message || t('devices.loadFailed')); + } finally { + setCompactDirectoryLoading(false); + } + }, [client, compact, loadWorkspaceList, setError, t]); + + const loadCompactWorkspaceCatalog = useCallback(async (expectedTargetEpoch: number) => { + if (!compact || !client) return; + setCompactDirectoryLoading(true); + try { + const workspaces = await sessionMgr.listRecentWorkspaces(); + if (client.controlTargetEpoch !== expectedTargetEpoch) return; + setWorkspaceList(workspaces); + } catch (error: unknown) { + if ( + client.controlTargetEpoch === expectedTargetEpoch + && !isRemoteControlTargetChangedError(error) + ) { + setError(String((error as { message?: string })?.message || error)); + } + } finally { + if (client.controlTargetEpoch === expectedTargetEpoch) { + setCompactDirectoryLoading(false); + } + } + }, [client, compact, sessionMgr, setError]); + + useEffect(() => { + if (!compact) return; + void loadCompactDirectory(); + }, [compact, loadCompactDirectory]); + + const handleSelectCompactDevice = useCallback(async (device: CompactDevice) => { + if (!client || !device.online || compactSwitchingDeviceId) return; + setCompactSelectedDeviceId(device.device_id); + if (client.pairedDeviceId === device.device_id) { + await loadCompactWorkspaceCatalog(client.controlTargetEpoch); + return; + } + const accountEpoch = client.delegatedAccountEpoch; + const targetEpoch = client.controlTargetEpoch; + setCompactSwitchingDeviceId(device.device_id); + setError(null); + try { + const ping = await client.sendDeviceRpc<{ resp?: string; ok?: boolean; error?: string }>( + device.device_id, + { + cmd: 'host_invoke', + command: 'peer_mode_ping', + args: {}, + }, + { retryable: true }, + ); + if ( + client.delegatedAccountEpoch !== accountEpoch + || client.controlTargetEpoch !== targetEpoch + ) return; + if (ping.resp === 'host_invoke_result' && ping.ok === false) { + throw new Error(ping.error || t('devices.switchFailed')); + } + client.setPairedDeviceId(device.device_id); + const switchedTargetEpoch = client.controlTargetEpoch; + resetForDeviceSwitch(); + setControlTarget({ + deviceId: device.device_id, + deviceName: device.device_name || null, + isHome: device.device_id === client.homeDeviceId, + }); + onControlTargetChanged?.(); + await loadCompactWorkspaceCatalog(switchedTargetEpoch); + } catch (error: unknown) { + if (isDelegatedIdentityChangedError(error)) return; + const message = String((error as { message?: string })?.message || error); + setError(message || t('devices.switchFailed')); + } finally { + setCompactSwitchingDeviceId((current) => ( + current === device.device_id ? null : current + )); + } + }, [ + client, + compactSwitchingDeviceId, + loadCompactWorkspaceCatalog, + onControlTargetChanged, + resetForDeviceSwitch, + setControlTarget, + setError, + t, + ]); + + const handleToggleCompactWorkspace = useCallback(async (workspace: RecentWorkspaceEntry) => { + const key = compactWorkspaceKey(workspace); + if (compactExpandedWorkspaces.has(key)) { + setCompactExpandedWorkspaces((current) => { + const next = new Set(current); + next.delete(key); + return next; + }); + return; + } + + setCompactExpandedWorkspaces((current) => new Set(current).add(key)); + if (compactWorkspaceStatuses[key] === 'ready' || compactWorkspaceStatuses[key] === 'loading') { + return; + } + + const targetEpoch = captureSessionListEpoch(); + if (targetEpoch === null) return; + setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'loading' })); + try { + const response = await sessionMgr.listSessions(workspace.path, PAGE_SIZE, 0, '', { + remoteConnectionId: workspace.remote_connection_id, + remoteSshHost: workspace.remote_ssh_host, + }); + if (!isSessionListCurrent(targetEpoch)) return; + setCompactWorkspaceSessions((current) => ({ ...current, [key]: response.sessions })); + setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'ready' })); + setCompactVisibleSessionCounts((current) => ({ ...current, [key]: 3 })); + } catch (error: unknown) { + if (!isSessionListCurrent(targetEpoch) || isRemoteControlTargetChangedError(error)) return; + setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'failed' })); + } + }, [ + captureSessionListEpoch, + compactExpandedWorkspaces, + compactWorkspaceStatuses, + isSessionListCurrent, + sessionMgr, + ]); + + const handleRetryCompactWorkspace = useCallback(async (workspace: RecentWorkspaceEntry) => { + const targetEpoch = captureSessionListEpoch(); + if (targetEpoch === null) return; + const key = compactWorkspaceKey(workspace); + setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'loading' })); + try { + const response = await sessionMgr.listSessions(workspace.path, PAGE_SIZE, 0, '', { + remoteConnectionId: workspace.remote_connection_id, + remoteSshHost: workspace.remote_ssh_host, + }); + if (!isSessionListCurrent(targetEpoch)) return; + setCompactWorkspaceSessions((current) => ({ ...current, [key]: response.sessions })); + setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'ready' })); + setCompactVisibleSessionCounts((current) => ({ ...current, [key]: 3 })); + } catch (error: unknown) { + if (!isSessionListCurrent(targetEpoch) || isRemoteControlTargetChangedError(error)) return; + setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'failed' })); + } + }, [captureSessionListEpoch, isSessionListCurrent, sessionMgr]); + + const handleCreateInCompactWorkspace = useCallback(async (workspace: RecentWorkspaceEntry) => { + if (creating || targetInitializingRef.current) return; + const targetEpoch = captureSessionListEpoch(); + if (targetEpoch === null) return; + setCreating(true); + try { + const identity = { + remoteConnectionId: workspace.remote_connection_id, + remoteSshHost: workspace.remote_ssh_host, + }; + const sessionId = await sessionMgr.createSession('code', undefined, workspace.path, identity); + if (!isSessionListCurrent(targetEpoch)) return; + const response = await sessionMgr.listSessions(workspace.path, PAGE_SIZE, 0, '', identity); + if (!isSessionListCurrent(targetEpoch)) return; + const key = compactWorkspaceKey(workspace); + setCompactExpandedWorkspaces((current) => new Set(current).add(key)); + setCompactWorkspaceSessions((current) => ({ ...current, [key]: response.sessions })); + setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'ready' })); + setCompactVisibleSessionCounts((current) => ({ ...current, [key]: 3 })); + onSelectSession(sessionId, t('sessions.remoteCodeSession'), true); + } catch (error: unknown) { + if (isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(error)) { + setError(String((error as { message?: string })?.message || error)); + } + } finally { + if (isSessionListCurrent(targetEpoch)) setCreating(false); + } + }, [ + captureSessionListEpoch, + creating, + isSessionListCurrent, + onSelectSession, + sessionMgr, + setError, + t, + ]); + const handleSelectWorkspace = useCallback(async (workspace: { path: string; name: string; @@ -944,13 +1268,324 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS const assistantDisplayName = currentAssistant?.name || t('shared.agents.default'); const isProMode = displayMode === 'pro'; + if (compact) { + const query = searchQuery.trim().toLocaleLowerCase(); + const visibleSessions = sessions.filter((session) => ( + query.length === 0 || (session.name || '').toLocaleLowerCase().includes(query) + )); + const compactWorkspaces = mergeCompactWorkspaces(workspaceList, currentWorkspace, sessions); + const projectedCompactDevices = compactDevices.some((device) => ( + device.device_id === client?.pairedDeviceId + )) || !client?.pairedDeviceId + ? compactDevices + : [{ + device_id: client.pairedDeviceId, + device_name: controlTarget?.deviceName || client.pairedDeviceId, + online: connectionHealth !== 'unreachable', + }, ...compactDevices]; + + return ( +
+
+

BitFun

+ +
+ + {compactSearchOpen && ( + setSearchQuery(event.target.value)} + placeholder={t('sessions.searchSessions')} + autoFocus + /> + )} + +
+
+
+

{t('devices.title')}

+ + + + +
+
+ {projectedCompactDevices.slice(0, compactVisibleDeviceCount).map((device) => { + const isCurrent = device.device_id === compactSelectedDeviceId; + const isSwitching = device.device_id === compactSwitchingDeviceId; + return ( + + ); + })} + {projectedCompactDevices.length > compactVisibleDeviceCount && ( + + )} +
+
+ + {compactSelectedDeviceId && ( +
+
+

{t('shared.features.workspace')}

+ +
+
+ {compactDirectoryLoading && compactWorkspaces.length === 0 && ( +

{t('common.loading')}

+ )} + {!compactDirectoryLoading && compactWorkspaces.length === 0 && ( +

{t('sessions.noWorkspaces')}

+ )} + {compactWorkspaces.slice(0, compactVisibleWorkspaceCount).map((workspace) => { + const key = compactWorkspaceKey(workspace); + const expanded = compactExpandedWorkspaces.has(key); + const projectedSessions = sessions.filter((session) => ( + (session.workspace_path || currentWorkspace?.path) === workspace.path + )); + const workspaceSessions = compactWorkspaceSessions[key] ?? projectedSessions; + const status = compactWorkspaceStatuses[key] + ?? (projectedSessions.length > 0 ? 'ready' : 'idle'); + const visibleCount = compactVisibleSessionCounts[key] ?? 3; + const current = currentWorkspace?.path === workspace.path + && currentWorkspace?.remote_connection_id === workspace.remote_connection_id; + return ( +
+
+ + + +
+ {expanded && ( +
+ {status === 'loading' &&

{t('sessions.loadingSessions')}

} + {status === 'failed' && } + {status === 'ready' && workspaceSessions.length === 0 &&

{t('sessions.noSessions')}

} + {workspaceSessions.slice(0, visibleCount).map((session) => ( + + ))} + {workspaceSessions.length > visibleCount && ( + + )} +
+ )} +
+ ); + })} + {compactWorkspaces.length > compactVisibleWorkspaceCount && ( + + )} +
+
+ )} + +
+
+

{t('shell.recentConversations')}

+
+ {visibleSessions.length === 0 ? ( +

{hasSearchQuery ? t('sessions.emptySearch') : t('sessions.noSessions')}

+ ) : ( +
+ {visibleSessions.slice(0, 6).map((session) => ( + + ))} +
+ )} +
+
+ +
+ + +
+ + {compactSettingsOpen && createPortal(( +
setCompactSettingsOpen(false)}> +
event.stopPropagation()}> +
+

{t('shared.features.settings')}

+ +
+
+

{t('settings.accountSection')}

+
+ + + {t('settings.currentAccount')} + {authenticatedUserLabel || t('settings.product')} + + {t('settings.signedIn')} +
+ +

{t('settings.generalSection')}

+
+ +
+ + {t('settings.language')} + + +
+
+ +

{t('settings.modelSection')}

+
+
+ + {t('settings.defaultModel')} + {t('settings.followDesktop')} +
+
+ +

{t('settings.devicesSection')}

+
+ {projectedCompactDevices.map((device) => { + const current = device.device_id === compactSelectedDeviceId; + return ( + + ); + })} +
+ +

{t('settings.aboutSection')}

+
+
{t('settings.product')}{t('settings.platform')}
+
+ + +
+
+
+ ), document.body)} + + {showDisconnectConfirm && createPortal(( +
setShowDisconnectConfirm(false)} + > +
event.stopPropagation()}> +
+ +
+

{t('sessions.disconnect')}

+

{t('sessions.disconnectConfirm')}

+
+ + +
+
+
+ ), document.body)} +
+ ); + } + return (
BitFun
-

{t('shared.product.remote')}

+

BitFun

{authenticatedUserLabel && ( { switch (connectionHealth) { case 'connected': return t('sessions.connectionConnected'); case 'checking': return t('sessions.connectionChecking'); case 'unreachable': return t('sessions.connectionUnreachable'); default: return t('sessions.connectionUnpaired'); } })()} /> @@ -1018,7 +1653,7 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS {showResumeCard && ( - -
+
+
+
+

{t('workspace.selectWorkspace')}

+ {controlTarget?.deviceName &&

{controlTarget.deviceName}

}
- ) : ( -
-
- -
-
- {t('workspace.noWorkspaceOpen')} -
-
- {t('workspace.noWorkspaceHint')} -
- {!showRecent && ( - - )} -
- )} + {onBack && ( + + )} +
- {showRecent && ( -
-
{t('workspace.recentWorkspaces')}
- {recentWorkspaces.length === 0 ? ( -
- {t('workspace.noRecentWorkspaces')} -
- ) : ( -
- {recentWorkspaces.map((ws) => ( +
+
+ {recentWorkspaces.length === 0 ? ( +
{t('workspace.noRecentWorkspaces')}
+ ) : ( +
+ {recentWorkspaces.map((ws) => { + const selected = workspaceInfo?.path === ws.path; + return ( - ))} -
- )} - {workspaceInfo?.has_workspace && ( - - )} -
- )} - - {switching && ( -
-
- {t('workspace.openingWorkspace')} -
- )} - - {error &&
{error}
} + ); + })} +
+ )} + {switching &&
} + {error &&
{error}
} +
); diff --git a/src/mobile-web/src/services/CloudAccountClient.ts b/src/mobile-web/src/services/CloudAccountClient.ts new file mode 100644 index 0000000000..e74da4ae62 --- /dev/null +++ b/src/mobile-web/src/services/CloudAccountClient.ts @@ -0,0 +1,212 @@ +import { argon2idAsync } from '@noble/hashes/argon2.js'; +import { decryptBytes, fromB64, toB64 } from './E2EEncryption'; + +interface AccountChallenge { + salt: string; + kdf_salt: string; + argon2_params: string; + wrapped_master_key: string; +} + +interface AccountAuthResponse { + token: string; + user_id: string; +} + +interface AccountKdfParams { + m: number; + t: number; + p: number; +} + +interface RelayErrorResponse { + error?: string; + retry_after_secs?: number; +} + +export interface CloudAccountSession { + token: string; + userId: string; + masterKey: Uint8Array; +} + +export class CloudAccountRequestError extends Error { + readonly status: number; + readonly retryAfterSeconds: number | null; + + constructor(message: string, status: number, retryAfterSeconds: number | null = null) { + super(message); + this.name = 'CloudAccountRequestError'; + this.status = status; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +const KDF_TIMEOUT_MS = 30_000; + +function generateRequestId(): string { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function validateKdfParams(params: AccountKdfParams, salt: Uint8Array): void { + if (salt.length < 8 || salt.length > 64 + || !Number.isInteger(params.m) || params.m < 8 * 1024 || params.m > 256 * 1024 + || !Number.isInteger(params.t) || params.t < 1 || params.t > 10 + || !Number.isInteger(params.p) || params.p < 1 || params.p > 16) { + throw new Error('Relay returned invalid account encryption parameters.'); + } +} + +async function derivePasswordHash( + password: string, + salt: Uint8Array, + params: AccountKdfParams, +): Promise { + validateKdfParams(params, salt); + let timeoutId = 0; + try { + return await Promise.race([ + argon2idAsync(password, salt, { + m: params.m, + t: params.t, + p: params.p, + dkLen: 32, + asyncTick: 10, + }), + new Promise((_, reject) => { + timeoutId = window.setTimeout( + () => reject(new Error('Account password derivation timed out.')), + KDF_TIMEOUT_MS, + ); + }), + ]); + } finally { + window.clearTimeout(timeoutId); + } +} + +async function requestJson( + relayUrl: string, + path: string, + body: object, +): Promise { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 35_000); + try { + const response = await fetch(`${relayUrl.replace(/\/+$/, '')}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const text = await response.text(); + let parsed: T | RelayErrorResponse | null = null; + try { + parsed = text ? JSON.parse(text) as T | RelayErrorResponse : null; + } catch { + // Use the HTTP status below when a proxy returned a non-JSON body. + } + if (!response.ok) { + const relayError = parsed as RelayErrorResponse | null; + throw new CloudAccountRequestError( + relayError?.error || `Relay request failed (HTTP ${response.status}).`, + response.status, + typeof relayError?.retry_after_secs === 'number' + ? relayError.retry_after_secs + : null, + ); + } + if (!parsed) throw new Error('Relay returned an empty account response.'); + return parsed as T; + } catch (error: unknown) { + if ((error as { name?: string })?.name === 'AbortError') { + throw new Error('Account request timed out.'); + } + throw error; + } finally { + window.clearTimeout(timeout); + } +} + +/** Browser implementation of the same zero-knowledge login used by HarmonyOS. */ +export class CloudAccountClient { + async login( + relayUrl: string, + username: string, + password: string, + deviceId: string, + ): Promise { + const normalizedUser = username.trim(); + if (!normalizedUser || normalizedUser.length > 128 + || !password || password.length > 1024) { + throw new Error('Invalid account credentials.'); + } + + const challenge = await requestJson( + relayUrl, + '/api/auth/login/challenge', + { username: normalizedUser }, + ); + const params = JSON.parse(challenge.argon2_params) as AccountKdfParams; + const salt = fromB64(challenge.salt); + const kdfSalt = fromB64(challenge.kdf_salt); + const kek = await derivePasswordHash(password, salt, params); + let passwordHash: Uint8Array | null = null; + try { + const wrappedParts = challenge.wrapped_master_key.split('.'); + if (wrappedParts.length !== 2) { + throw new Error('Relay returned an invalid wrapped master key.'); + } + let masterKey: Uint8Array; + try { + masterKey = decryptBytes(kek, fromB64(wrappedParts[0]), fromB64(wrappedParts[1])); + } catch { + throw new Error('Invalid username or password.'); + } + if (masterKey.length !== 32) { + masterKey.fill(0); + throw new Error('Invalid username or password.'); + } + + passwordHash = await derivePasswordHash(password, kdfSalt, params); + const requestId = generateRequestId(); + try { + const auth = await requestJson( + relayUrl, + '/api/auth/login', + { + username: normalizedUser, + password_hash: toB64(passwordHash), + device_id: deviceId, + device_name: 'Mobile Browser', + device_kind: 'mobile', + request_id: requestId, + }, + ); + if (!auth.token?.trim() || !auth.user_id?.trim()) { + masterKey.fill(0); + throw new Error('Relay returned an invalid account identity.'); + } + return { + token: auth.token, + userId: auth.user_id, + masterKey, + }; + } catch (error) { + masterKey.fill(0); + throw error; + } + } finally { + kek.fill(0); + passwordHash?.fill(0); + } + } +} diff --git a/src/mobile-web/src/services/E2EEncryption.ts b/src/mobile-web/src/services/E2EEncryption.ts index 9ec7955a5d..3a30e325ee 100644 --- a/src/mobile-web/src/services/E2EEncryption.ts +++ b/src/mobile-web/src/services/E2EEncryption.ts @@ -50,13 +50,19 @@ export async function decrypt( dataB64: string, nonceB64: string, ): Promise { - const nonce = fromB64(nonceB64); - const data = fromB64(dataB64); - const cipher = gcm(key, nonce); - const pt = cipher.decrypt(data); + const pt = decryptBytes(key, fromB64(dataB64), fromB64(nonceB64)); return new TextDecoder().decode(pt); } +/** Decrypt a raw AES-256-GCM payload, including its appended authentication tag. */ +export function decryptBytes( + key: Uint8Array, + data: Uint8Array, + nonce: Uint8Array, +): Uint8Array { + return gcm(key, nonce).decrypt(data); +} + export function toB64(buf: Uint8Array): string { let s = ''; buf.forEach(b => (s += String.fromCharCode(b))); diff --git a/src/mobile-web/src/services/RelayHttpClient.ts b/src/mobile-web/src/services/RelayHttpClient.ts index 94d3c71e6f..946c39f3a3 100644 --- a/src/mobile-web/src/services/RelayHttpClient.ts +++ b/src/mobile-web/src/services/RelayHttpClient.ts @@ -22,6 +22,7 @@ interface DelegatedIdentitySnapshot { userId: string | null; homeDeviceId: string | null; generation: number; + source: 'paired' | 'direct'; } interface DelegatedAccountIdentity { @@ -112,6 +113,8 @@ export class RelayHttpClient { ) => void>(); /** The current control-target device_id (for sendDeviceRpc). */ private pairedDeviceIdValue: string | null = null; + /** This browser's relay device id. Never offer it as a remote control target. */ + private controllerDeviceIdValue: string | null = null; private controlTargetEpochValue = 0; private controlTargetListeners = new Set<(snapshot: ControlTargetSnapshot) => void>(); /** The QR-paired desktop's device_id (the "home" device of this session). */ @@ -211,6 +214,7 @@ export class RelayHttpClient { this.sharedKey = await deriveSharedKey(this.keyPair, desktopPub); const deviceId = identity.mobileInstallId; + this.controllerDeviceIdValue = deviceId; const deviceName = this.getMobileDeviceName(); const userId = identity.userId.trim(); const mobileInstallId = identity.mobileInstallId.trim(); @@ -300,6 +304,9 @@ export class RelayHttpClient { * not-logged-in case. */ async requestDelegatedIdentity(options?: { force?: boolean }): Promise { + // Direct account login already owns a first-party device credential. It + // must never fall back to a QR-room command, especially on forced refresh. + if (this.delegatedIdentity?.source === 'direct') return true; if (!options?.force && this.hasDelegatedIdentity) return true; const requestGeneration = ++this.delegatedIdentityRequestEpoch; if (options?.force) { @@ -326,6 +333,7 @@ export class RelayHttpClient { userId: resp.user_id ?? null, homeDeviceId, generation: ++this.delegatedIdentityGenerationValue, + source: 'paired', }; this.delegatedIdentity = nextIdentity; @@ -388,6 +396,7 @@ export class RelayHttpClient { const targetEpoch = this.controlTargetEpochValue; this.clearDelegatedIdentity(); this.commitDelegatedAccountUnavailable(); + this.controllerDeviceIdValue = null; if (this.controlTargetEpochValue === targetEpoch) { // Disconnect is an explicit ownership boundary even when this session // never received delegated credentials and was using only the QR room. @@ -499,10 +508,68 @@ export class RelayHttpClient { return this.delegatedIdentity !== null; } + /** + * Install an account session obtained by this browser itself. The existing + * account data plane can then list devices and issue encrypted device RPCs + * without requiring a QR-room connection first. + */ + installDirectAccountIdentity(identity: { + token: string; + masterKey: Uint8Array; + userId: string; + deviceId: string; + }): void { + const token = identity.token.trim(); + const userId = identity.userId.trim(); + const deviceId = identity.deviceId.trim(); + if (!token || !userId || !deviceId || identity.masterKey.length !== 32) { + throw new Error('Relay returned an invalid account identity.'); + } + this.controllerDeviceIdValue = deviceId; + + const hadAccountIdentity = this.delegatedAccountIdentity !== null; + const nextAccountIdentity: DelegatedAccountIdentity = { + userId, + masterKey: identity.masterKey.slice(), + homeDeviceId: null, + }; + const accountChanged = delegatedAccountChanged( + this.delegatedAccountIdentity, + nextAccountIdentity, + ); + + this.delegatedIdentityRequestEpoch += 1; + this.delegatedIdentityRefreshOwner = null; + this.delegatedIdentity = { + token, + masterKey: identity.masterKey.slice(), + userId, + homeDeviceId: null, + generation: ++this.delegatedIdentityGenerationValue, + source: 'direct', + }; + if (accountChanged) this.delegatedAccountEpochValue += 1; + this.delegatedAccountIdentity = nextAccountIdentity; + this.homeDeviceId = null; + this.setPairedDeviceId(null); + if (accountChanged) { + this.emitDelegatedAccountOwnerChange({ + kind: hadAccountIdentity ? 'replacement' : 'initial', + epoch: this.delegatedAccountEpochValue, + userId, + homeDeviceId: null, + }); + } + } + get pairedDeviceId(): string | null { return this.pairedDeviceIdValue; } + get controllerDeviceId(): string | null { + return this.controllerDeviceIdValue; + } + /** * Commit a control target and advance its ownership epoch. Advancing even * when the device id repeats is intentional: A -> B -> A must invalidate @@ -596,6 +663,7 @@ export class RelayHttpClient { if (!this.isDelegatedIdentityCurrent(failedIdentity)) { return this.hasDelegatedIdentity; } + if (failedIdentity.source === 'direct') return false; try { return await this.requestDelegatedIdentity({ force: true }); } catch { diff --git a/src/mobile-web/src/services/pairingLink.ts b/src/mobile-web/src/services/pairingLink.ts new file mode 100644 index 0000000000..375a4f6cf4 --- /dev/null +++ b/src/mobile-web/src/services/pairingLink.ts @@ -0,0 +1,48 @@ +export function normalizeRelayUrl(value: string): string | null { + try { + const normalized = value + .replace(/^wss:\/\//, 'https://') + .replace(/^ws:\/\//, 'http://') + .replace(/\/ws\/?$/, '') + .replace(/\/$/, ''); + const url = new URL(normalized); + if (!['http:', 'https:'].includes(url.protocol) + || !url.hostname + || url.username + || url.password + || url.search + || url.hash) { + return null; + } + return url.toString().replace(/\/$/, ''); + } catch { + return null; + } +} + +export function validPairingSecret(room: string | null, publicKey: string | null): boolean { + return !!room + && room.length <= 128 + && /^[A-Za-z0-9_-]+$/.test(room) + && !['_store', 'page-data', 'pages'].includes(room) + && !!publicKey + && publicKey.length <= 512 + && /^[A-Za-z0-9+/=_-]+$/.test(publicKey); +} + +/** Validate a Desktop-generated remote-control URL before navigating to it. */ +export function parseScannedPairingLink(value: string, baseHref = window.location.href): string | null { + try { + const url = new URL(value.trim(), baseHref); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null; + if (!(url.hash === '#/pair' || url.hash.startsWith('#/pair?'))) return null; + + const params = new URLSearchParams(url.hash.replace(/^#\/pair\??/, '')); + if (!validPairingSecret(params.get('room'), params.get('pk'))) return null; + const relay = params.get('relay'); + if (relay && !normalizeRelayUrl(relay)) return null; + return url.toString(); + } catch { + return null; + } +} diff --git a/src/mobile-web/src/styles/components/adaptive-shell.scss b/src/mobile-web/src/styles/components/adaptive-shell.scss new file mode 100644 index 0000000000..bf68d34320 --- /dev/null +++ b/src/mobile-web/src/styles/components/adaptive-shell.scss @@ -0,0 +1,697 @@ +/* + * Harmony-inspired adaptive shell. + * Compact screens keep the existing navigation stack; wide screens use the + * same content in a persistent master-detail composition. + */ + +button, +input, +textarea { + font-family: inherit; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--bf-color-content-primary); + outline-offset: 2px; +} + +.remote-shell { + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--bf-color-surface-canvas); +} + +.remote-shell__master { + position: relative; + z-index: 2; + flex: 0 0 clamp(320px, 27vw, 368px); + width: clamp(320px, 27vw, 368px); + min-width: 0; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--bf-color-border-subtle); + background: var(--bf-color-surface-canvas); +} + +.remote-shell__detail { + position: relative; + z-index: 1; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--bf-color-surface-canvas); +} + +.remote-home { + display: grid; + place-items: center; + width: 100%; + height: 100%; + padding: 48px; + background: + radial-gradient(circle at 50% 44%, var(--bf-color-accent-surface), transparent 34%), + var(--bf-color-surface-canvas); +} + +.remote-home__content { + width: min(100%, 560px); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.remote-home__mark { + position: relative; + display: grid; + place-items: center; + width: 82px; + height: 82px; + margin-bottom: 26px; + border: 1px solid var(--bf-color-border-subtle); + border-radius: 26px; + background: var(--bf-color-surface-panel); + box-shadow: var(--bf-shadow-lg); + + img { width: 48px; height: 48px; object-fit: contain; } +} + +.remote-home__pulse { + position: absolute; + top: 9px; + right: 9px; + width: 9px; + height: 9px; + border-radius: 50%; + border: 2px solid var(--bf-color-surface-panel); + background: var(--bf-color-content-muted); + + &--connected { background: var(--bf-color-status-success-content); } + &--checking { background: var(--bf-color-status-warning-content); } + &--unreachable { background: var(--bf-color-status-danger-content); } +} + +.remote-home__eyebrow { + margin-bottom: 12px; + color: var(--bf-color-content-muted); + font-size: 11px; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.remote-home h2 { + max-width: 480px; + margin: 0; + font-family: var(--bf-font-family-sans); + color: var(--bf-color-content-primary); + font-size: clamp(30px, 3vw, 44px); + font-weight: 650; + line-height: 1.14; + letter-spacing: -0.04em; +} + +.remote-home p { + max-width: 470px; + margin: 16px 0 30px; + color: var(--bf-color-content-secondary); + font-size: 14px; + line-height: 1.65; +} + +.remote-home__workspace { + display: flex; + align-items: center; + gap: 13px; + width: min(100%, 420px); + min-height: 66px; + padding: 10px 14px 10px 11px; + border: 1px solid var(--bf-color-border-subtle); + border-radius: 19px; + background: var(--bf-color-surface-panel); + color: var(--bf-color-content-primary); + text-align: left; + box-shadow: var(--bf-shadow-sm); + cursor: pointer; + transition: transform var(--motion-fast), background var(--motion-fast), box-shadow var(--motion-fast); + + &:hover { + background: var(--bf-color-surface-tertiary); + box-shadow: var(--bf-shadow-base); + } + + &:active { transform: translateY(1px); } + + > svg { flex: 0 0 auto; color: var(--bf-color-content-muted); } +} + +.remote-home__workspace-icon { + display: grid; + place-items: center; + flex: 0 0 44px; + width: 44px; + height: 44px; + border-radius: 14px; + background: var(--bf-color-surface-tertiary); + color: var(--bf-color-content-primary); +} + +.remote-home__workspace-copy { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 3px; + + > span { + overflow: hidden; + color: var(--bf-color-content-primary); + font-size: 14px; + font-weight: var(--font-weight-semibold); + text-overflow: ellipsis; + white-space: nowrap; + } + + small { + overflow: hidden; + color: var(--bf-color-content-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +/* Shared compact visual language */ +.session-list__header, +.chat-page__header, +.devices-page__header, +.workspace-page__header { + min-height: 64px; + background: color-mix(in srgb, var(--bf-color-surface-canvas) 90%, transparent); + border-bottom-color: var(--bf-color-border-subtle); + box-shadow: none; +} + +.session-list__header-copy h1, +.devices-page__title, +.workspace-page__header h1 { + font-family: var(--bf-font-family-sans); + font-weight: var(--font-weight-semibold); + letter-spacing: -0.02em; +} + +.session-list__theme-btn, +.session-list__disconnect-btn, +.session-list__devices-btn, +.devices-page__back-btn, +.devices-page__refresh-btn, +.workspace-page__back, +.chat-page__back, +.chat-page__theme-btn { + width: 40px; + height: 40px; + border: 1px solid var(--bf-color-border-subtle); + border-radius: 50%; + background: var(--bf-color-surface-panel); + color: var(--bf-color-content-secondary); + box-shadow: var(--bf-shadow-sm); + + &:hover { + background: var(--bf-color-surface-tertiary); + color: var(--bf-color-content-primary); + } +} + +.mobile-lang-btn.session-list__language-btn { + width: 40px; + min-width: 40px; + height: 40px; + background: var(--bf-color-surface-panel); + box-shadow: var(--bf-shadow-sm); +} + +.session-list__items { + gap: 14px; + padding: 14px 14px max(24px, env(safe-area-inset-bottom, 0px)); +} + +.session-list__panel { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.session-list__section-head { + align-items: center; + margin: 4px 4px 10px; +} + +.session-list__section-kicker { display: none; } + +.session-list__section-title { + margin: 0; + font-family: var(--bf-font-family-sans); + font-size: 15px; + font-weight: var(--font-weight-semibold); + letter-spacing: -0.015em; +} + +.session-list__workspace-bar, +.session-list__assistant-bar { + min-height: 62px; + padding: 10px 12px; + border-color: var(--bf-color-border-subtle); + border-radius: 17px; + background: var(--bf-color-surface-panel); + box-shadow: var(--bf-shadow-xs); + + &:hover { background: var(--bf-color-surface-tertiary); } +} + +.session-list__workspace-icon, +.session-list__assistant-icon { + width: 40px; + height: 40px; + border-radius: 13px; + background: var(--bf-color-surface-tertiary); + color: var(--bf-color-content-primary); +} + +.session-list__workspace-label, +.session-list__assistant-label { + color: var(--bf-color-content-muted); + font-size: 9px; + letter-spacing: 0.1em; +} + +.session-list__workspace-branch { + background: var(--bf-color-surface-tertiary); + color: var(--bf-color-content-secondary); +} + +.session-list__mode-toggle { + padding: 3px; + border-color: transparent; + border-radius: 14px; + background: var(--bf-color-surface-tertiary); +} + +.session-list__mode-toggle-btn { + min-height: 38px; + border-radius: 11px; + + &.is-active, + &.is-active:first-child, + &.is-active:last-child { + background: var(--bf-color-surface-panel); + color: var(--bf-color-content-primary); + box-shadow: var(--bf-shadow-sm); + } +} + +.session-list__resume-card { + padding: 12px; + border: 1px solid var(--bf-color-border-subtle); + border-radius: 17px; + background: var(--bf-color-surface-panel); + box-shadow: var(--bf-shadow-sm); + + &.is-selected { + border-color: var(--bf-color-border-strong); + background: var(--bf-color-surface-tertiary); + box-shadow: inset 3px 0 0 var(--bf-color-content-primary); + } +} + +.session-list__resume-label { color: var(--bf-color-content-muted); } + +.session-list__create-row, +.session-list__cards { gap: 4px; } + +.session-list__create-btn, +.session-list__item { + min-height: 58px; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: 12px; + background: transparent; + box-shadow: none; + animation-duration: 220ms; + + &:hover { + border-color: transparent; + background: var(--bf-color-surface-tertiary); + } + + &:active:not(:disabled) { + transform: none; + background: var(--bf-color-action-neutral-surface-hover); + } +} + +.session-list__create-btn { + border-color: var(--bf-color-border-subtle); + background: var(--bf-color-surface-panel); + margin-bottom: 5px; +} + +.session-list__create-btn--code, +.session-list__create-btn--cowork, +.session-list__create-btn--claw { + border-color: var(--bf-color-border-subtle); +} + +.session-list__create-icon, +.session-list__item-icon, +.session-list__create-btn--cowork .session-list__create-icon, +.session-list__create-btn--claw .session-list__create-icon, +.session-list__item-icon--cowork, +.session-list__item-icon--Cowork, +.session-list__item-icon--claw, +.session-list__item-icon--Claw { + width: 38px; + height: 38px; + border-radius: 12px; + background: var(--bf-color-surface-tertiary); + color: var(--bf-color-content-secondary); +} + +.session-list__item.is-selected { + background: var(--bf-color-surface-tertiary); + box-shadow: inset 3px 0 0 var(--bf-color-content-primary); +} + +.session-list__item-name { font-weight: var(--font-weight-medium); } + +.session-list__agent-badge { + padding: 0 6px; + border-color: transparent; + background: transparent; + color: var(--bf-color-content-muted); + + &--code, + &--agentic, + &--cowork, + &--Cowork, + &--claw, + &--Claw { + border-color: transparent; + background: transparent; + color: var(--bf-color-content-muted); + } +} + +.session-list__search { + min-height: 40px; + padding: 8px 12px; + border: 0; + border-radius: 13px; + background: var(--bf-color-surface-tertiary); +} + +.session-list__empty { + border: 0; + background: var(--bf-color-surface-tertiary); + box-shadow: none; +} + +.workspace-page__header-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.workspace-page__back { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0; + cursor: pointer; +} + +.devices-page__body, +.workspace-page__content { + width: 100%; +} + +.devices-page__device, +.workspace-page__current-card, +.workspace-page__recent-item, +.workspace-page__no-workspace { + border-color: var(--bf-color-border-subtle); + background: var(--bf-color-surface-panel); + box-shadow: var(--bf-shadow-xs); +} + +.devices-page__device { + min-height: 68px; + padding: 11px 12px; + border-radius: 16px; + + &.is-current { + border-color: var(--bf-color-border-strong); + background: var(--bf-color-surface-tertiary); + box-shadow: inset 3px 0 0 var(--bf-color-content-primary); + } +} + +.devices-page__device-icon, +.devices-page__device.is-current .devices-page__device-icon { + width: 42px; + height: 42px; + border-color: var(--bf-color-border-subtle); + background: var(--bf-color-surface-tertiary); + color: var(--bf-color-content-secondary); +} + +.devices-page__badge--current { + border-color: var(--bf-color-border-subtle); + background: var(--bf-color-surface-panel); + color: var(--bf-color-content-secondary); +} + +.workspace-page__btn--primary { + background: var(--bf-color-action-primary-background); + color: var(--bf-color-action-primary-content); + + &:hover { background: var(--bf-color-action-primary-hover); } +} + +/* Conversation follows the Harmony header / timeline / floating composer rhythm. */ +.chat-page { position: relative; } + +.chat-page__header { + padding: 0 16px; + min-height: 64px; +} + +.chat-page__header-row { gap: 8px; } + +.chat-page__title { + font-family: var(--bf-font-family-sans); + font-size: 15px; + font-weight: var(--font-weight-medium); +} + +.chat-page__messages { + gap: 20px; + padding: 22px max(16px, calc((100% - 760px) / 2)) 128px; +} + +.chat-msg--user { align-items: flex-end; } + +.chat-msg__user-card { + width: auto; + max-width: min(86%, 620px); +} + +.chat-msg__user-content { + padding: 10px 13px; + border: 0; + border-radius: 16px 16px 5px 16px; + background: var(--bf-color-surface-tertiary); +} + +.chat-page__input-wrap { + bottom: max(14px, env(safe-area-inset-bottom, 0px)); + max-width: 760px; + padding: 0 16px; +} + +.chat-page__input-box { + border: 1px solid var(--bf-color-border-subtle); + background: color-mix(in srgb, var(--bf-color-surface-panel) 94%, transparent); + box-shadow: 0 10px 34px color-mix(in srgb, var(--bf-color-content-primary) 12%, transparent); +} + +.chat-page__input-wrap:not(.is-expanded) .chat-page__input-box { + min-height: 48px; + max-height: 48px; + padding-left: 18px; + border-radius: 24px; +} + +.chat-page__input-wrap.is-expanded .chat-page__input-box { + border-radius: 20px; + padding: 14px 10px 2px; +} + +.chat-page__send-btn { + width: 30px; + height: 30px; + border-color: var(--bf-color-action-primary-background); + background: var(--bf-color-action-primary-background); + color: var(--bf-color-action-primary-content); + + &:not(:disabled) { + border-color: var(--bf-color-action-primary-background); + background: var(--bf-color-action-primary-background); + color: var(--bf-color-action-primary-content); + } + + &:disabled { + border-color: transparent; + background: var(--bf-color-surface-tertiary); + color: var(--bf-color-content-disabled); + } +} + +.chat-page__input-wrap:not(.is-expanded) .chat-page__send-btn { + width: 28px; + height: 28px; +} + +.chat-page__scroll-to-bottom { + bottom: 92px; + border-color: var(--bf-color-border-subtle); + background: var(--bf-color-surface-panel); +} + +@media (min-width: 900px) { + .mobile-reconnect-banner { left: clamp(320px, 27vw, 368px); } + + .remote-shell__master .session-list__header { + min-height: 72px; + padding: 0 14px; + } + + .remote-shell__master .session-list__header-actions { gap: 4px; } + + .remote-shell__master .session-list__theme-btn, + .remote-shell__master .session-list__disconnect-btn, + .remote-shell__master .session-list__devices-btn, + .remote-shell__master .mobile-lang-btn.session-list__language-btn { + width: 34px; + min-width: 34px; + height: 34px; + box-shadow: none; + } + + .remote-shell__master .session-list__header-target { display: none; } + + .remote-shell__master .session-list__items { + padding: 14px 12px 28px; + gap: 13px; + } + + .chat-page--wide .chat-page__back { + visibility: hidden; + pointer-events: none; + } + + .chat-page--wide .chat-page__input-wrap, + .chat-page--wide .chat-page__scroll-to-bottom { + position: absolute; + } + + .chat-page__header, + .devices-page__header, + .workspace-page__header { + min-height: 72px; + } + + .chat-page__messages { padding-top: 30px; } + + .devices-page__body, + .workspace-page__content { + max-width: 760px; + margin: 0 auto; + padding: 30px 28px 48px; + } + + .session-list__picker-overlay, + .session-list__menu-overlay { + align-items: center; + padding: 24px; + } + + .session-list__picker-modal, + .session-list__menu-sheet { + max-width: 420px; + border: 1px solid var(--bf-color-border-subtle); + border-radius: 24px; + box-shadow: var(--bf-shadow-xl); + } + + .session-list__menu-handle { display: none; } +} + +@media (max-width: 899px) { + .session-list__header { + padding-left: max(14px, env(safe-area-inset-left, 0px)); + padding-right: max(14px, env(safe-area-inset-right, 0px)); + } + + .session-list__header-actions { gap: 4px; } + + .session-list__theme-btn, + .session-list__disconnect-btn, + .session-list__devices-btn, + .mobile-lang-btn.session-list__language-btn { + width: 34px; + min-width: 34px; + height: 34px; + box-shadow: none; + } + + .session-list__header-target { display: none; } + + .chat-page__header, + .devices-page__header, + .workspace-page__header { + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + } + + .chat-page__messages { + padding-left: max(16px, env(safe-area-inset-left, 0px)); + padding-right: max(16px, env(safe-area-inset-right, 0px)); + } +} + +@media (max-width: 390px) { + .session-list__header-copy h1 { font-size: 15px; } + .session-list__header-account-name { max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } + .session-list__items { padding-inline: 10px; } + .session-list__create-desc { display: none; } + .chat-page__header-workspace { font-size: 12px; } +} + +@media (prefers-reduced-motion: reduce) { + .remote-home__workspace, + .chat-page__input-box { + transition: none; + } +} diff --git a/src/mobile-web/src/styles/components/chat-input.scss b/src/mobile-web/src/styles/components/chat-input.scss index 4f45c30b20..ea0f49e1d8 100644 --- a/src/mobile-web/src/styles/components/chat-input.scss +++ b/src/mobile-web/src/styles/components/chat-input.scss @@ -447,21 +447,47 @@ } &.is-stop { - background: var(--bf-color-status-danger-content) !important; - border-color: var(--bf-color-status-danger-content) !important; + background: var(--bf-color-action-primary-background) !important; + border-color: transparent !important; color: var(--bf-color-action-primary-content) !important; opacity: 1 !important; - box-shadow: var(--bf-shadow-sm); + box-shadow: none; + + &:hover:not(:disabled) { + background: var(--bf-color-action-primary-hover) !important; + box-shadow: var(--bf-shadow-base); + transform: scale(1.05); + } &:active { transform: scale(0.92); } + + &:disabled { + background: var(--bf-color-action-primary-background) !important; + color: var(--bf-color-action-primary-content) !important; + cursor: wait; + opacity: 0.78 !important; + } } } -.chat-page__stream-actions { - display: inline-flex; - align-items: center; - gap: 6px; - flex-shrink: 0; +.chat-page__stop-glyph { + width: 9px; + height: 9px; + border-radius: 2px; + background: currentColor; +} + +.chat-page__stop-spinner { + width: 14px; + height: 14px; + border: 2px solid color-mix(in srgb, currentColor 34%, transparent); + border-top-color: currentColor; + border-radius: 50%; + animation: chat-stop-spin 0.8s linear infinite; +} + +@keyframes chat-stop-spin { + to { transform: rotate(360deg); } } // ── Image previews ─────────────────────────────────────────────────────────── diff --git a/src/mobile-web/src/styles/components/chat.scss b/src/mobile-web/src/styles/components/chat.scss index 2e7977e86c..8a92c8513f 100644 --- a/src/mobile-web/src/styles/components/chat.scss +++ b/src/mobile-web/src/styles/components/chat.scss @@ -69,12 +69,43 @@ } .chat-page__header-right { + position: relative; display: flex; align-items: center; gap: var(--size-gap-1); flex-shrink: 0; } +.chat-page__header-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 20; + display: grid; + min-width: 176px; + padding: 8px; + border: 1px solid var(--bf-color-border-subtle); + border-radius: 16px; + background: var(--bf-color-surface-panel); + box-shadow: var(--bf-shadow-lg); +} + +.chat-page__header-menu button { + min-height: 42px; + padding: 0 12px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--bf-color-content-primary); + font: inherit; + text-align: left; + cursor: pointer; + + &:active { + background: var(--bf-color-surface-subtle); + } +} + .chat-page__theme-btn { display: inline-flex; align-items: center; diff --git a/src/mobile-web/src/styles/components/harmony-native.scss b/src/mobile-web/src/styles/components/harmony-native.scss new file mode 100644 index 0000000000..3870823a11 --- /dev/null +++ b/src/mobile-web/src/styles/components/harmony-native.scss @@ -0,0 +1,2382 @@ +/* + * Shared visual contract with the HarmonyOS mobile surface. + * Geometry follows MobileDesignTokens.ets; colors follow the native resource + * palettes. Keep product behavior in the existing React pages and adapt only + * their presentation here. + */ + +:root { + --harmony-page: var(--bf-color-surface-canvas); + --harmony-card: var(--bf-color-surface-panel); + --harmony-soft: var(--bf-color-surface-tertiary); + --harmony-line: var(--bf-color-border-subtle); + --harmony-gutter: 16px; + --harmony-sheet-radius: 34px; + --harmony-control: 44px; + --harmony-composer-height: 52px; +} + +.mobile-app { + background: var(--harmony-page); +} + +.remote-home { + position: relative; + display: flex; + align-items: stretch; + justify-content: flex-start; + flex-direction: column; + width: 100%; + height: 100%; + padding: 0; + background: var(--harmony-page); + overflow: hidden; +} + +.remote-home__header { + display: grid; + grid-template-columns: 44px minmax(0, 1fr) 44px; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + height: 76px; + min-height: 76px; + padding: 8px 16px; +} + +.remote-home__menu { + display: grid; + place-items: center; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid var(--harmony-line); + border-radius: 22px; + background: var(--harmony-card); + color: var(--bf-color-content-primary); + box-shadow: 0 3px 10px color-mix(in srgb, var(--bf-color-content-primary) 8%, transparent); +} + +.remote-home__menu--placeholder { + visibility: hidden; +} + +.remote-home__heading { + min-width: 0; + text-align: center; +} + +.remote-home__heading h2 { + margin: 0; + color: var(--bf-color-content-primary); + font-size: 18px; + font-weight: 700; + line-height: 24px; + letter-spacing: 0; +} + +.remote-home__heading p { + max-width: 100%; + margin: 0; + overflow: hidden; + color: var(--bf-color-content-secondary); + font-size: 14px; + line-height: 21px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.remote-home__content { + display: flex; + align-items: center; + justify-content: center; + flex: 1 1 0; + min-height: 0; + width: 100%; + padding: 0 36px 24px; +} + +.remote-home__message { + max-width: 420px; + margin: 0; + color: var(--bf-color-content-secondary); + font-size: 17px; + font-weight: 400; + line-height: 25px; + text-align: center; +} + +.remote-home__empty-state { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 360px; +} + +.remote-home__empty-state h3 { + margin: 0; + color: var(--bf-color-content-primary); + font-size: 20px; + font-weight: 700; + line-height: 26px; + text-align: center; +} + +.remote-home__empty-state p { + max-width: 420px; + margin: 10px 0 0; + color: var(--bf-color-content-secondary); + font-size: 14px; + line-height: 21px; + text-align: center; +} + +.remote-home__empty-state button { + min-width: 148px; + height: 46px; + margin-top: 22px; + padding: 0 28px; + border: 0; + border-radius: 24px; + background: var(--bf-color-action-primary-background); + color: var(--bf-color-action-primary-content); + font-size: 15px; + font-weight: 500; +} + +.remote-home__composer-wrap { + width: 100%; + padding: 8px 32px max(12px, env(safe-area-inset-bottom, 0px)); +} + +.remote-home__composer { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) 40px; + align-items: center; + gap: 5px; + width: 100%; + height: 52px; + padding: 0 8px; + border: 0; + border-radius: 26px; + background: var(--harmony-card); + color: var(--bf-color-content-primary); + box-shadow: 0 2px 10px color-mix(in srgb, var(--bf-color-content-primary) 5%, transparent); + text-align: left; +} + +.remote-home__composer span { + overflow: hidden; + color: var(--bf-color-content-secondary); + font-size: 16px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.remote-home__composer-plus, +.remote-home__composer-mic { + justify-self: center; +} + +.remote-home__composer:disabled { + cursor: wait; + opacity: 0.72; +} + +.remote-home__composer-spinner { + justify-self: center; + width: 18px; + height: 18px; +} + +/* Harmony compact navigation shell -------------------------------------- */ + +.compact-remote-shell { + --compact-sidebar-width: min(326px, calc(100vw - 48px)); + position: absolute; + inset: 0; + overflow: hidden; + background: var(--harmony-page); +} + +.compact-remote-shell__sidebar { + position: absolute; + inset: 0 auto 0 0; + z-index: 1; + width: var(--compact-sidebar-width); + overflow: hidden; + background: var(--harmony-page); + visibility: hidden; + transition: visibility 0s linear 250ms; +} + +.compact-remote-shell.is-sidebar-open .compact-remote-shell__sidebar { + visibility: visible; + transition-delay: 0s; +} + +.compact-remote-shell__main { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + border-radius: 0; + background: var(--harmony-page); + transform: translateX(0) scale(1); + transform-origin: left center; + transition: transform 250ms ease-out, border-radius 250ms ease-out, box-shadow 250ms ease-out; +} + +.compact-remote-shell__scrim { + position: absolute; + inset: 0; + z-index: 3; + display: none; + padding: 0; + border: 0; + background: transparent; +} + +.compact-remote-shell.is-sidebar-open .compact-remote-shell__main { + border-radius: 28px; + box-shadow: -10px 0 34px color-mix(in srgb, var(--bf-color-content-primary) 14%, transparent); + transform: translateX(var(--compact-sidebar-width)) scale(0.985, 0.992); +} + +.compact-remote-shell.is-sidebar-open .compact-remote-shell__scrim { + display: block; + left: var(--compact-sidebar-width); +} + +/* Harmony authenticated sidebar ----------------------------------------- */ + +.harmony-sidebar { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 4px 20px 16px; + overflow: hidden; + background: var(--harmony-page); + color: var(--bf-color-content-primary); +} + +.harmony-sidebar button, +.harmony-sidebar input { + font: inherit; +} + +.harmony-sidebar__header { + display: flex; + align-items: center; + width: 100%; + height: 50px; + min-height: 50px; +} + +.harmony-sidebar__header h1 { + margin: 0; + font-size: 20px; + font-weight: 500; + line-height: 26px; +} + +.harmony-sidebar__round-action { + display: grid; + place-items: center; + width: 44px; + height: 44px; + margin-left: auto; + padding: 0; + border: 1px solid var(--harmony-line); + border-radius: 22px; + background: var(--harmony-card); + color: var(--bf-color-content-primary); + box-shadow: 0 6px 14px color-mix(in srgb, var(--bf-color-content-primary) 8%, transparent); +} + +.harmony-sidebar__search { + width: 100%; + height: 42px; + margin-top: 12px; + padding: 0 14px; + border: 0; + border-radius: 8px; + outline: 0; + background: var(--harmony-soft); + color: var(--bf-color-content-primary); + font-size: 14px; +} + +.harmony-sidebar__search::placeholder { + color: var(--bf-color-content-muted); +} + +.harmony-sidebar__scroll { + flex: 1; + min-height: 0; + padding-bottom: 92px; + overflow-y: auto; + scrollbar-width: none; +} + +.harmony-sidebar__scroll::-webkit-scrollbar { + display: none; +} + +.harmony-sidebar__section { + width: 100%; + margin-top: 18px; +} + +.harmony-sidebar__section-heading { + display: flex; + align-items: center; + width: 100%; + height: 42px; +} + +.harmony-sidebar__section-heading h2 { + margin: 0; + color: var(--bf-color-content-secondary); + font-size: 14px; + font-weight: 500; + line-height: 21px; +} + +.harmony-sidebar__section-heading button, +.harmony-sidebar__heading-actions button { + display: grid; + place-items: center; + width: 34px; + height: 38px; + margin-left: auto; + padding: 0; + border: 0; + background: transparent; + color: var(--bf-color-content-primary); +} + +.harmony-sidebar__heading-actions { + display: flex; + align-items: center; + margin-left: auto; +} + +.harmony-sidebar__heading-actions button { + margin-left: 0; +} + +.harmony-sidebar__rows { + width: 100%; +} + +.harmony-sidebar__device-row, +.harmony-sidebar__workspace-row, +.harmony-sidebar__session-row, +.harmony-sidebar__more-row { + display: flex; + align-items: center; + width: 100%; + height: 52px; + padding: 0 4px 0 8px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--bf-color-content-primary); + text-align: left; +} + +.harmony-sidebar__workspace-row, +.harmony-sidebar__session-row { + height: 46px; +} + +.harmony-sidebar__device-row:disabled:not(.is-current) { + opacity: 0.58; +} + +.harmony-sidebar__device-icon, +.harmony-sidebar__folder-icon { + display: grid; + place-items: center; + flex: 0 0 24px; + width: 24px; + height: 24px; + margin-right: 10px; +} + +.harmony-sidebar__row-label { + min-width: 0; + flex: 1; + overflow: hidden; + font-size: 15px; + font-weight: 400; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.harmony-sidebar__device-row.is-current .harmony-sidebar__row-label { + font-weight: 500; +} + +.harmony-sidebar__status { + flex: 0 0 8px; + width: 8px; + height: 8px; + margin: 0 10px; + border-radius: 50%; + background: var(--bf-color-content-muted); +} + +.harmony-sidebar__status.is-online { + background: var(--bf-color-status-success-content); +} + +.harmony-sidebar__chevron { + display: grid; + place-items: center; + flex: 0 0 20px; + width: 20px; + color: var(--bf-color-content-secondary); + transition: transform 160ms ease-out; +} + +.harmony-sidebar__chevron.is-expanded { + transform: rotate(90deg); +} + +.harmony-sidebar__row-spinner { + flex: 0 0 14px; + width: 14px; + height: 14px; + margin: 0 7px; +} + +.harmony-sidebar__section--workspaces { + margin-top: 16px; +} + +.harmony-sidebar__workspace-group { + width: 100%; + margin-bottom: 6px; +} + +.harmony-sidebar__workspace-row { + padding: 0 6px; +} + +.harmony-sidebar__workspace-row.is-current .harmony-sidebar__folder-icon, +.harmony-sidebar__workspace-row.is-current .harmony-sidebar__row-label { + color: var(--bf-color-content-primary); + font-weight: 500; +} + +.harmony-sidebar__workspace-disclosure, +.harmony-sidebar__workspace-main, +.harmony-sidebar__row-plus { + height: 40px; + padding: 0; + border: 0; + background: transparent; + color: var(--bf-color-content-primary); +} + +.harmony-sidebar__workspace-disclosure { + display: grid; + place-items: center; + flex: 0 0 24px; + width: 24px; + color: var(--bf-color-content-secondary); + transition: transform 160ms ease-out; +} + +.harmony-sidebar__workspace-disclosure.is-expanded { + transform: rotate(90deg); +} + +.harmony-sidebar__workspace-main { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + text-align: left; +} + +.harmony-sidebar__row-plus { + display: grid; + place-items: center; + flex: 0 0 30px; + width: 30px; +} + +.harmony-sidebar__workspace-sessions { + width: 100%; +} + +.harmony-sidebar__workspace-session, +.harmony-sidebar__workspace-message { + display: flex; + align-items: center; + width: 100%; + min-height: 40px; + padding: 0 10px 0 38px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--bf-color-content-secondary); + font-size: 13px; + line-height: 18px; + text-align: left; +} + +.harmony-sidebar__session-icon { + display: grid; + place-items: center; + flex: 0 0 26px; + width: 26px; + height: 26px; + margin-right: 8px; + color: var(--bf-color-content-secondary); +} + +.harmony-sidebar__session-icon svg { + width: 18px; + height: 18px; +} + +.harmony-sidebar__workspace-session.is-current { + background: var(--harmony-soft); + color: var(--bf-color-content-primary); +} + +.harmony-sidebar__workspace-session > svg { + flex: 0 0 30px; + width: 30px; + margin-left: 4px; + opacity: 0.56; +} + +.harmony-sidebar__workspace-message { + gap: 8px; + margin: 0; +} + +.harmony-sidebar__workspace-message .spinner { + width: 13px; + height: 13px; +} + +.harmony-sidebar__more-row { + height: 40px; + gap: 8px; + padding-left: 12px; + color: var(--bf-color-content-secondary); + font-size: 13px; + line-height: 19px; +} + +.harmony-sidebar__more-row--nested { + padding-left: 38px; +} + +.harmony-sidebar__section--conversations { + margin-top: 16px; +} + +.harmony-sidebar__section-heading--plain { + height: auto; + margin-bottom: 6px; +} + +.harmony-sidebar__session-row { + padding-left: 8px; +} + +.harmony-sidebar__session-row.is-current { + background: var(--harmony-soft); +} + +.harmony-sidebar__session-more { + flex: 0 0 34px; + width: 34px; + color: var(--bf-color-content-secondary); + font-size: 11px; + letter-spacing: -1px; + text-align: center; + opacity: 0.62; +} + +.harmony-sidebar__empty { + margin: 8px 0; + color: var(--bf-color-content-secondary); + font-size: 14px; + font-weight: 500; + line-height: 21px; +} + +.harmony-sidebar__footer { + position: absolute; + right: 20px; + bottom: 16px; + left: 20px; + z-index: 2; + display: flex; + align-items: center; + height: 56px; + background: linear-gradient(to bottom, transparent, var(--harmony-page) 45%); +} + +.harmony-sidebar__new-chat, +.harmony-sidebar__settings { + display: flex; + align-items: center; + justify-content: center; + height: 46px; + padding: 0; + border: 1px solid var(--harmony-line); + background: var(--harmony-card); + color: var(--bf-color-content-primary); + box-shadow: 0 6px 14px color-mix(in srgb, var(--bf-color-content-primary) 8%, transparent); +} + +.harmony-sidebar__new-chat { + width: 98px; + gap: 9px; + border-radius: 23px; + font-size: 15px; + font-weight: 500; +} + +.harmony-sidebar__settings { + width: 44px; + margin-left: auto; + border: 0; + border-radius: 22px; +} + +.harmony-sidebar__sheet-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: flex-start; + padding-top: 6px; + background: color-mix(in srgb, var(--bf-color-content-primary) 18%, transparent); +} + +.harmony-sidebar__settings-sheet { + display: flex; + flex-direction: column; + width: 100%; + height: calc(100% - 6px); + padding: 0; + overflow: hidden; + border-radius: 28px 28px 0 0; + background: var(--harmony-page); + box-shadow: 0 -12px 36px color-mix(in srgb, var(--bf-color-content-primary) 16%, transparent); +} + +.harmony-sidebar__settings-sheet > header { + display: flex; + align-items: center; + flex: 0 0 56px; + height: 56px; + padding: 0 12px 0 20px; + border-bottom: 1px solid var(--harmony-line); +} + +.harmony-sidebar__settings-sheet h2 { + margin: 0; + color: var(--bf-color-content-primary); + font-size: 18px; + font-weight: 700; +} + +.harmony-sidebar__settings-sheet > header button { + display: grid; + place-items: center; + width: 36px; + height: 36px; + margin-left: auto; + padding: 0; + border: 0; + border-radius: 18px; + background: transparent; + color: var(--bf-color-content-primary); +} + +.harmony-sidebar__settings-scroll { + flex: 1; + min-height: 0; + padding: 22px 20px max(34px, env(safe-area-inset-bottom, 0px)); + overflow-y: auto; + scrollbar-width: none; +} + +.harmony-sidebar__settings-scroll::-webkit-scrollbar { + display: none; +} + +.harmony-sidebar__settings-scroll h3 { + margin: 0 8px 10px; + color: var(--bf-color-content-secondary); + font-size: 13px; + font-weight: 500; + line-height: 19px; +} + +.harmony-sidebar__account-card { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + min-height: 72px; + margin-bottom: 24px; + padding: 12px 18px; + border-radius: 16px; + background: var(--harmony-card); +} + +.harmony-sidebar__account-avatar { + display: grid; + place-items: center; + flex: 0 0 46px; + width: 46px; + height: 46px; + border-radius: 23px; + background: var(--harmony-soft); + color: var(--bf-color-content-primary); + font-size: 18px; + font-weight: 600; +} + +.harmony-sidebar__account-copy { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + gap: 3px; +} + +.harmony-sidebar__account-copy strong { + overflow: hidden; + color: var(--bf-color-content-primary); + font-size: 15px; + font-weight: 500; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.harmony-sidebar__account-copy small, +.harmony-sidebar__settings-row small { + overflow: hidden; + color: var(--bf-color-content-secondary); + font-size: 13px; + font-weight: 400; + line-height: 19px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.harmony-sidebar__verified { + color: var(--bf-color-status-success-content); + font-size: 13px; + font-weight: 500; +} + +.harmony-sidebar__settings-card { + width: 100%; + margin-bottom: 24px; + overflow: hidden; + border-radius: 16px; + background: var(--harmony-card); +} + +.harmony-sidebar__settings-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 56px; + padding: 12px 18px; + border: 0; + border-bottom: 1px solid var(--harmony-line); + border-radius: 0; + background: transparent; + color: var(--bf-color-content-primary); + font-size: 16px; + text-align: left; +} + +.harmony-sidebar__settings-row:last-child { + border-bottom: 0; +} + +.harmony-sidebar__settings-row > span:nth-child(2) { + flex: 1; + min-width: 0; +} + +.harmony-sidebar__settings-row-icon { + display: grid; + place-items: center; + flex: 0 0 23px; + width: 23px; + height: 23px; + color: var(--bf-color-content-secondary); + font-size: 15px; +} + +.harmony-sidebar__settings-chevron { + flex: 0 0 auto !important; + color: var(--bf-color-content-secondary); + font-size: 22px; + line-height: 20px; +} + +.harmony-sidebar__settings-language { + min-width: 34px; + height: 32px; + padding: 0 8px; + border: 0; + border-radius: 10px; + background: var(--harmony-soft); + color: var(--bf-color-content-secondary); + box-shadow: none; +} + +.harmony-sidebar__settings-card--devices { + padding: 8px; +} + +.harmony-sidebar__settings-device { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 58px; + padding: 8px 10px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--bf-color-content-primary); + text-align: left; +} + +.harmony-sidebar__settings-device.is-current { + background: var(--harmony-soft); +} + +.harmony-sidebar__settings-device:disabled { + opacity: 0.58; +} + +.harmony-sidebar__settings-device-icon { + display: grid; + place-items: center; + flex: 0 0 28px; + width: 28px; + color: var(--bf-color-content-secondary); +} + +.harmony-sidebar__settings-row--static span:first-child { + flex: 1; +} + +.harmony-sidebar__settings-disconnect { + width: 100%; + height: 52px; + margin-top: 4px; + border: 0; + border-radius: 16px; + background: var(--harmony-card); + color: var(--bf-color-status-danger-content); + font-size: 15px; + font-weight: 500; +} + +@keyframes harmonySidebarSpin { + to { transform: rotate(360deg); } +} + +.harmony-sidebar .is-spinning { + animation: harmonySidebarSpin 800ms linear infinite; +} + +/* Pair/account sheet ------------------------------------------------------ */ + +.pairing-page { + display: flex; + align-items: stretch; + justify-content: center; + min-height: 100dvh; + padding: max(6px, env(safe-area-inset-top, 0px)) 0 0; + background: var(--bf-color-overlay-scrim); + overflow: hidden; +} + +.pairing-page__shell { + display: block; + width: min(100%, 520px); + min-height: calc(100dvh - max(6px, env(safe-area-inset-top, 0px))); + margin: 0 auto; + overflow: hidden; + border: 0; + border-radius: var(--harmony-sheet-radius) var(--harmony-sheet-radius) 0 0; + background: var(--harmony-page); + box-shadow: none; +} + +.pairing-page__hero { + display: none; +} + +.pairing-page__panel { + display: grid; + grid-template-rows: 56px minmax(0, 1fr); + justify-content: stretch; + min-height: inherit; + padding: 0; + background: var(--harmony-page); +} + +.pairing-page__header { + display: grid; + grid-template-columns: minmax(0, 1fr) 44px; + align-items: center; + gap: 10px; + width: 100%; + min-width: 0; + padding: 0 12px 0 20px; +} + +.pairing-page__back { + display: grid; + place-items: center; + width: 44px; + height: 44px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--bf-color-content-primary); + cursor: pointer; +} + +.pairing-page__header-spacer { + min-width: 0; +} + +.pairing-page__form { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + min-height: 0; + padding: 0; +} + +.pairing-page__scroll { + display: flex; + min-height: 0; + overflow-y: auto; + scrollbar-width: none; +} + +.pairing-page__scroll::-webkit-scrollbar { + display: none; +} + +.pairing-page__form-content { + align-self: center; + width: 100%; + max-width: 520px; + padding: 8px 20px 18px; + margin: auto; +} + +.pairing-page__title { + width: 100%; + margin: 0; + color: var(--bf-color-content-primary); + font-size: 22px; + font-weight: 700; + line-height: 28px; + text-align: center; +} + +.pairing-page__intro { + margin: 8px 0 24px; + color: var(--bf-color-content-secondary); + font-size: 14px; + line-height: 21px; + text-align: center; +} + +.pairing-page__credentials { + overflow: hidden; + border: 1px solid var(--harmony-line); + border-radius: 24px; + background: var(--harmony-card); + box-shadow: 0 5px 16px color-mix(in srgb, var(--bf-color-content-primary) 3%, transparent); +} + +.pairing-page__field { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + height: 60px; + gap: 12px; + padding: 0 12px 0 18px; +} + +.pairing-page__credentials .pairing-page__field + .pairing-page__field::before { + content: ''; + position: absolute; + top: 0; + right: 16px; + left: 54px; + height: 1px; + background: var(--harmony-line); +} + +.pairing-page__field-label { + display: none; +} + +.pairing-page__input { + flex: 1; + min-width: 0; + width: auto; + height: 60px; + min-height: 60px; + padding: 0 4px 0 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--bf-color-content-primary); + font-size: 16px; + outline: none; + box-shadow: none; +} + +.pairing-page__input::placeholder { + color: var(--bf-color-content-muted); +} + +.pairing-page__input:-webkit-autofill, +.pairing-page__input:-webkit-autofill:hover, +.pairing-page__input:-webkit-autofill:focus { + animation: pairingAutofillReconcile 1ms; + -webkit-text-fill-color: var(--bf-color-content-primary); + box-shadow: 0 0 0 1000px var(--harmony-card) inset; + transition: background-color 9999s ease-out; +} + +@keyframes pairingAutofillReconcile { + from { opacity: 0.999; } + to { opacity: 1; } +} + +.pairing-page__input:focus { + border: 0; + background: transparent; + box-shadow: none; +} + +.pairing-page__password-field { + position: relative; +} + +.pairing-page__password-field .pairing-page__input { + padding-right: 48px; +} + +.pairing-page__field-icon { + display: grid; + place-items: center; + flex: 0 0 24px; + width: 24px; + height: 24px; + color: var(--bf-color-content-secondary); +} + +.pairing-page__password-toggle { + position: absolute; + top: 8px; + right: 8px; + display: grid; + place-items: center; + width: 44px; + height: 44px; + padding: 0; + border: 0; + background: transparent; + color: var(--bf-color-content-primary); +} + +.pairing-page__sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.pairing-page__advanced { + margin-top: 14px; + overflow: hidden; + border: 1px solid var(--harmony-line); + border-radius: 20px; + background: var(--harmony-card); + color: var(--bf-color-content-secondary); +} + +.pairing-page__advanced summary { + display: flex; + align-items: center; + min-height: 58px; + gap: 14px; + padding: 0 18px; + color: var(--bf-color-content-primary); + font-size: 15px; + font-weight: 500; + list-style: none; + cursor: pointer; +} + +.pairing-page__advanced summary::-webkit-details-marker { + display: none; +} + +.pairing-page__advanced summary::after { + content: '⌄'; + margin-left: auto; + color: var(--bf-color-content-muted); + font-size: 18px; + transform-origin: center; + transition: transform 180ms ease; +} + +.pairing-page__advanced-icon { + display: grid; + place-items: center; + flex: 0 0 24px; + color: var(--bf-color-content-secondary); +} + +.pairing-page__advanced[open] summary::after { + transform: rotate(180deg); +} + +.pairing-page__advanced-actions { + display: flex; + align-items: stretch; + flex-direction: column; + gap: 8px; + padding: 14px 18px 18px; + border-top: 1px solid var(--harmony-line); +} + +.pairing-page__relay-field { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + color: var(--bf-color-content-secondary); + font-size: 12px; + line-height: 16px; +} + +.pairing-page__relay-field input { + width: 100%; + height: 48px; + padding: 0 14px; + border: 1px solid var(--harmony-line); + border-radius: 14px; + outline: 0; + background: var(--harmony-page); + color: var(--bf-color-content-primary); + font: inherit; + font-size: 14px; +} + +.pairing-page__relay-field input:focus { + border-color: var(--bf-color-content-secondary); +} + +.pairing-page__theme-btn { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 0 14px; + border: 1px solid var(--harmony-line); + border-radius: 20px; + background: var(--harmony-card); + color: var(--bf-color-content-secondary); +} + +.pairing-page__note { + margin: 10px 18px 16px; + color: var(--bf-color-content-muted); + font-size: 12px; + line-height: 18px; +} + +.pairing-page__error { + margin: 14px 0 0; + padding: 10px 14px; + border: 0; + border-radius: 8px; + background: var(--bf-color-status-danger-surface); + color: var(--bf-color-status-danger-content); + font-size: 13px; + line-height: 19px; +} + +.pairing-page__retry { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + width: 100%; + min-height: 56px; + margin: 0; + border: 0; + border-radius: 18px; + background: var(--bf-color-action-primary-background); + color: var(--bf-color-action-primary-content); + font-size: 15px; + font-weight: 700; + cursor: pointer; +} + +.pairing-page__retry:disabled { + background: var(--bf-color-action-primary-background); + color: var(--bf-color-action-primary-content); + opacity: 0.28; +} + +.pairing-page__form:has(.pairing-page__input--username:placeholder-shown:not(:-webkit-autofill)) .pairing-page__retry, +.pairing-page__form:has(.pairing-page__input--password:placeholder-shown:not(:-webkit-autofill)) .pairing-page__retry { + opacity: 0.28; + pointer-events: none; +} + +.pairing-page__action { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 20px max(22px, env(safe-area-inset-bottom, 0px)); +} + +.pairing-page__scan-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + min-height: 52px; + padding: 0 18px; + border: 1px solid var(--harmony-line); + border-radius: 18px; + background: var(--harmony-card); + color: var(--bf-color-content-primary); + font-size: 15px; + font-weight: 600; + cursor: pointer; +} + +.pairing-page__scan-action:active:not(:disabled) { + background: var(--harmony-soft); + transform: scale(0.99); +} + +.pairing-page__scan-action:disabled { + color: var(--bf-color-content-disabled); + cursor: default; + opacity: var(--bf-opacity-disabled); +} + +.pairing-page__progress { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + color: var(--bf-color-content-secondary); +} + +/* QR scanner ------------------------------------------------------------- */ + +.qr-scanner-sheet__backdrop { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: max(6px, env(safe-area-inset-top, 0px)); + background: var(--bf-color-overlay-scrim); +} + +.qr-scanner-sheet { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: min(100%, 520px); + height: calc(100dvh - max(6px, env(safe-area-inset-top, 0px))); + overflow: hidden; + border-radius: var(--harmony-sheet-radius) var(--harmony-sheet-radius) 0 0; + background: var(--harmony-page); + box-shadow: var(--bf-shadow-xl); +} + +.qr-scanner-sheet__header { + display: grid; + grid-template-columns: minmax(0, 1fr) 44px; + align-items: start; + gap: 14px; + padding: 24px 16px 16px 24px; +} + +.qr-scanner-sheet__header h2 { + margin: 0; + color: var(--bf-color-content-primary); + font-size: 22px; + font-weight: 700; + line-height: 28px; +} + +.qr-scanner-sheet__header p { + margin: 6px 0 0; + color: var(--bf-color-content-secondary); + font-size: 14px; + line-height: 21px; +} + +.qr-scanner-sheet__header button { + display: grid; + place-items: center; + width: 44px; + height: 44px; + padding: 0; + border: 0; + border-radius: 22px; + background: var(--harmony-card); + color: var(--bf-color-content-primary); + box-shadow: var(--bf-shadow-sm); +} + +.qr-scanner-sheet__content { + min-height: 0; + padding: 6px 24px max(28px, env(safe-area-inset-bottom, 0px)); + overflow-y: auto; +} + +.qr-scanner-sheet__camera { + position: relative; + width: min(100%, 320px); + aspect-ratio: 1; + margin: 4px auto 20px; + overflow: hidden; + border: 1px solid var(--harmony-line); + border-radius: 28px; + background: var(--harmony-soft); +} + +.qr-scanner-sheet__camera video, +.qr-scanner-sheet__shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.qr-scanner-sheet__camera video { + object-fit: cover; +} + +.qr-scanner-sheet__shade { + pointer-events: none; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bf-color-content-primary) 10%, transparent); +} + +.qr-scanner-sheet__corner { + position: absolute; + width: 46px; + height: 46px; + border-color: var(--bf-color-action-primary-content); + border-style: solid; + filter: drop-shadow(0 1px 2px var(--bf-color-overlay-scrim)); +} + +.qr-scanner-sheet__corner--tl { top: 20px; left: 20px; border-width: 4px 0 0 4px; border-radius: 12px 0 0; } +.qr-scanner-sheet__corner--tr { top: 20px; right: 20px; border-width: 4px 4px 0 0; border-radius: 0 12px 0 0; } +.qr-scanner-sheet__corner--bl { bottom: 20px; left: 20px; border-width: 0 0 4px 4px; border-radius: 0 0 0 12px; } +.qr-scanner-sheet__corner--br { right: 20px; bottom: 20px; border-width: 0 4px 4px 0; border-radius: 0 0 12px; } + +.qr-scanner-sheet__starting { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + background: color-mix(in srgb, var(--harmony-soft) 88%, transparent); + color: var(--bf-color-content-secondary); + font-size: 13px; +} + +.qr-scanner-sheet__error { + width: min(100%, 360px); + margin: 0 auto 14px; + padding: 11px 14px; + border-radius: 14px; + background: var(--bf-color-status-danger-surface); + color: var(--bf-color-status-danger-content); + font-size: 13px; + line-height: 19px; + text-align: center; +} + +.qr-scanner-sheet__image-action { + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + width: min(100%, 360px); + min-height: 48px; + margin: 0 auto; + border: 1px solid var(--harmony-line); + border-radius: 16px; + background: var(--harmony-card); + color: var(--bf-color-content-primary); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.qr-scanner-sheet__image-action input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.qr-scanner-sheet__manual { + width: min(100%, 360px); + margin: 22px auto 0; +} + +.qr-scanner-sheet__manual > label { + display: block; + margin: 0 4px 8px; + color: var(--bf-color-content-secondary); + font-size: 13px; +} + +.qr-scanner-sheet__manual > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.qr-scanner-sheet__manual input { + min-width: 0; + height: 48px; + padding: 0 13px; + border: 1px solid var(--harmony-line); + border-radius: 14px; + outline: 0; + background: var(--harmony-card); + color: var(--bf-color-content-primary); + font: inherit; + font-size: 14px; +} + +.qr-scanner-sheet__manual input:focus { + border-color: var(--bf-color-content-secondary); +} + +.qr-scanner-sheet__manual button { + min-width: 76px; + height: 48px; + padding: 0 16px; + border: 0; + border-radius: 14px; + background: var(--bf-color-action-primary-background); + color: var(--bf-color-action-primary-content); + font-size: 14px; + font-weight: 600; +} + +.qr-scanner-sheet__manual button:disabled { + opacity: 0.28; +} + +/* Root/sidebar-like session surface -------------------------------------- */ + +.session-list { + background: var(--harmony-page); +} + +.session-list__header { + min-height: 72px; + padding: max(10px, env(safe-area-inset-top, 0px)) 20px 6px; + border: 0; + background: var(--harmony-page); + box-shadow: none; +} + +.session-list__logo { + display: none; +} + +.session-list__header-copy h1 { + font-size: 20px; + font-weight: 700; + line-height: 26px; + letter-spacing: -0.02em; +} + +.session-list__header-account-name { + margin-top: 1px; + color: var(--bf-color-content-muted); + font-size: 12px; +} + +.session-list__header-actions { + gap: 6px; +} + +.session-list__devices-btn, +.session-list__theme-btn, +.session-list__disconnect-btn, +.mobile-lang-btn.session-list__language-btn { + width: 38px; + min-width: 38px; + height: 38px; + border: 1px solid var(--harmony-line); + border-radius: 19px; + background: var(--harmony-card); + box-shadow: 0 6px 14px color-mix(in srgb, var(--bf-color-content-primary) 8%, transparent); +} + +.session-list__items { + gap: 12px; + padding: 4px 20px max(24px, env(safe-area-inset-bottom, 0px)); +} + +.session-list__resume-card { + min-height: 72px; + padding: 10px 12px; + border: 1px solid var(--harmony-line); + border-radius: 8px; + background: var(--harmony-card); + box-shadow: none; +} + +.session-list__resume-icon, +.session-list__item-icon, +.session-list__create-icon, +.session-list__workspace-icon, +.session-list__assistant-icon { + border: 0; + border-radius: 8px; + background: var(--harmony-soft); + color: var(--bf-color-content-secondary); +} + +.session-list__mode-toggle { + min-height: 48px; + padding: 3px; + border: 0; + border-radius: 24px; + background: var(--harmony-soft); +} + +.session-list__mode-toggle-btn { + min-height: 42px; + border-radius: 21px; +} + +.session-list__mode-toggle-btn.is-active { + background: var(--harmony-card); + box-shadow: 0 2px 8px color-mix(in srgb, var(--bf-color-content-primary) 8%, transparent); +} + +.session-list__workspace-bar, +.session-list__assistant-bar, +.session-list__create-btn { + min-height: 58px; + padding: 8px 12px; + border: 1px solid var(--harmony-line); + border-radius: 8px; + background: var(--harmony-card); + box-shadow: none; +} + +.session-list__panel { + margin-top: 4px; +} + +.session-list__section-head { + min-height: 42px; + margin: 0; +} + +.session-list__section-title { + color: var(--bf-color-content-secondary); + font-size: 14px; + font-weight: 500; +} + +.session-list__section-meta { + color: var(--bf-color-content-muted); + font-size: 12px; +} + +.session-list__search { + min-height: 42px; + margin: 0 0 8px; + border: 0; + border-radius: 8px; + background: var(--harmony-soft); +} + +.session-list__cards { + gap: 0; +} + +.session-list__item { + min-height: 60px; + padding: 8px 4px; + border: 0; + border-bottom: 1px solid var(--harmony-line); + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.session-list__item.is-selected { + border-color: var(--harmony-line); + background: var(--harmony-soft); +} + +.session-list__item-name { + font-size: 15px; + font-weight: 500; +} + +.session-list__item-time { + color: var(--bf-color-content-muted); + font-size: 12px; +} + +.session-list__agent-badge { + border: 0; + background: transparent; + color: var(--bf-color-content-muted); +} + +.session-list__picker-overlay, +.session-list__menu-overlay, +.chat-msg__menu-overlay { + background: var(--bf-color-overlay-scrim); +} + +.session-list__picker-modal, +.session-list__rename-modal, +.session-list__confirm-modal, +.session-list__menu-sheet, +.chat-msg__menu-sheet { + border-radius: 34px 34px 0 0; + background: var(--harmony-card); +} + +/* Native full-page secondary surfaces ----------------------------------- */ + +.devices-page { + background: var(--harmony-page); +} + +.devices-page__header { + min-height: 92px; + padding: max(18px, env(safe-area-inset-top, 0px)) 28px 0; + border: 0; + background: var(--harmony-page); +} + +.devices-page__back-btn { + width: 48px; + height: 48px; + border: 0; + border-radius: 24px; + background: var(--harmony-soft); + box-shadow: none; +} + +.devices-page__refresh-btn { + width: 48px; + height: 48px; + border: 1px solid var(--harmony-line); + border-radius: 24px; + background: var(--harmony-card); + box-shadow: none; +} + +.devices-page__title { + font-size: 22px; + font-weight: 700; + line-height: 28px; +} + +.devices-page__body { + padding: 10px 28px max(32px, env(safe-area-inset-bottom, 0px)); +} + +.devices-page__device, +.devices-page__empty-card { + border: 1px solid var(--harmony-line); + border-radius: 8px; + background: var(--harmony-card); + box-shadow: none; +} + +.devices-page__list { + overflow: hidden; + border: 1px solid var(--harmony-line); + border-radius: 8px; + background: var(--harmony-card); +} + +.devices-page__device { + min-height: 60px; + border: 0; + border-bottom: 1px solid var(--harmony-line); + border-radius: 0; +} + +.devices-page__device:last-child { + border-bottom: 0; +} + +/* Workspace picker sheet ------------------------------------------------- */ + +.workspace-page { + display: flex; + align-items: stretch; + width: 100%; + height: 100%; + padding-top: max(6px, env(safe-area-inset-top, 0px)); + overflow: hidden; + background: var(--bf-color-overlay-scrim); +} + +.workspace-page__sheet { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 10px 18px 0; + overflow: hidden; + border-radius: 34px 34px 0 0; + background: var(--harmony-page); +} + +.workspace-page__header { + display: flex; + align-items: center; + flex: 0 0 66px; + width: 100%; + height: 66px; + min-height: 66px; + padding: 0; + border: 0; + background: transparent; +} + +.workspace-page__heading-copy { + min-width: 0; + flex: 1; +} + +.workspace-page__heading-copy h1 { + width: 100%; + margin: 0; + color: var(--bf-color-content-primary); + font-size: 20px; + font-weight: 700; + line-height: 26px; +} + +.workspace-page__heading-copy p { + width: 100%; + margin: 2px 0 0; + overflow: hidden; + color: var(--bf-color-content-secondary); + font-size: 13px; + line-height: 19px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-page__close { + display: grid; + place-items: center; + flex: 0 0 40px; + width: 40px; + height: 40px; + margin-left: 12px; + padding: 0; + border: 0; + border-radius: 20px; + background: var(--harmony-soft); + color: var(--bf-color-content-secondary); +} + +.workspace-page__divider { + flex: 0 0 1px; + height: 1px; + background: var(--harmony-line); +} + +.workspace-page__content { + position: relative; + flex: 1; + min-height: 0; + padding: 10px 0 max(18px, env(safe-area-inset-bottom, 0px)); + overflow-y: auto; + scrollbar-width: none; +} + +.workspace-page__content::-webkit-scrollbar { + display: none; +} + +.workspace-page__recent-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.workspace-page__recent-item { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + height: 68px; + min-height: 68px; + padding: 0 8px 0 10px; + border: 0; + border-radius: 14px; + background: var(--harmony-page); + color: var(--bf-color-content-primary); + text-align: left; + box-shadow: none; + animation: none; +} + +.workspace-page__recent-item.is-selected { + background: var(--harmony-soft); +} + +.workspace-page__recent-item-icon { + display: grid; + place-items: center; + flex: 0 0 34px; + width: 34px; + height: 34px; + margin-right: 12px; + border-radius: 10px; + background: var(--harmony-card); +} + +.workspace-page__recent-item-copy { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 3px; +} + +.workspace-page__recent-item-name, +.workspace-page__recent-item-path { + display: block; + width: 100%; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-page__recent-item-name { + color: var(--bf-color-content-primary); + font-size: 16px; + font-weight: 400; + line-height: 24px; +} + +.workspace-page__recent-item.is-selected .workspace-page__recent-item-name { + font-weight: 500; +} + +.workspace-page__recent-item-path { + color: var(--bf-color-content-secondary); + font-family: inherit; + font-size: 12px; + line-height: 16px; +} + +.workspace-page__recent-item-trailing { + display: grid; + place-items: center; + flex: 0 0 40px; + width: 40px; + height: 40px; + color: var(--bf-color-content-secondary); + font-size: 22px; +} + +.workspace-page__recent-item.is-selected .workspace-page__recent-item-trailing { + color: var(--bf-color-content-primary); + font-size: 18px; +} + +.workspace-page__recent-empty { + display: grid; + place-items: center; + min-height: 240px; + color: var(--bf-color-content-secondary); + font-size: 14px; + line-height: 21px; + text-align: center; +} + +.workspace-page__switching { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--harmony-page) 72%, transparent); +} + +.workspace-page__error { + margin: 8px 0 0; + padding: 8px 12px; + border-radius: 10px; + background: var(--bf-color-status-danger-surface); + color: var(--bf-color-status-danger-content); + font-size: 13px; + line-height: 19px; + text-align: center; +} + +/* Conversation ------------------------------------------------------------ */ + +.chat-page { + background: var(--harmony-page); +} + +.chat-page__header { + height: 76px; + min-height: 76px; + padding: 8px var(--harmony-gutter); + border: 0; + background: var(--harmony-page); + backdrop-filter: none; +} + +.chat-page__header-row { + display: grid; + grid-template-columns: 48px minmax(0, 1fr) 48px; + gap: 8px; +} + +.chat-page__back, +.chat-page__theme-btn { + position: relative; + width: 48px; + height: 48px; + border: 0; + border-radius: 24px; + background: transparent; + color: var(--bf-color-content-primary); + box-shadow: none; +} + +.chat-page__back::before, +.chat-page__theme-btn::before { + content: ''; + position: absolute; + inset: 3px; + z-index: 0; + border: 0.5px solid var(--harmony-line); + border-radius: 21px; + background: var(--harmony-card); + box-shadow: 0 7px 18px color-mix(in srgb, var(--bf-color-content-primary) 7%, transparent); +} + +.chat-page__back > svg, +.chat-page__theme-btn > svg { + position: relative; + z-index: 1; +} + +.chat-page__title { + font-size: 17px; + font-weight: 600; + line-height: 22px; +} + +.chat-page__header-workspace, +.chat-page__workspace-name, +.chat-page__workspace-branch { + color: var(--bf-color-content-secondary); + font-size: 14px; + line-height: 18px; +} + +.chat-page__messages { + gap: 12px; + padding: 0 20px 92px; +} + +.chat-msg--user { + align-items: flex-end; + padding: 8px 0 12px; +} + +.chat-msg__user-card { + justify-content: flex-end; + width: auto; + max-width: 70%; +} + +.chat-msg__user-content { + width: auto; + max-width: 100%; + padding: 10px; + border: 0; + border-radius: 18px; + background: var(--harmony-soft); + color: var(--bf-color-content-primary); + font-size: 14px; + line-height: 21px; +} + +.chat-msg__assistant-content { + width: 100%; + max-width: 100%; + padding: 0 2px 10px 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--bf-color-content-primary); + font-size: 14px; + line-height: 21px; +} + +.chat-msg--assistant { + padding: 2px 0 10px; +} + +.chat-page__input-wrap { + bottom: max(12px, env(safe-area-inset-bottom, 0px)); + width: min(100%, 520px); + max-width: 520px; + padding: 0 32px; +} + +.chat-page__input-wrap:not(.is-expanded) { + max-width: 520px; +} + +.chat-page__input-wrap:not(.is-expanded) .chat-page__input-box { + min-height: var(--harmony-composer-height); + max-height: var(--harmony-composer-height); + padding: 0 8px; + border: 0; + border-radius: 26px; + gap: 5px; + background: var(--harmony-card); + box-shadow: 0 2px 10px color-mix(in srgb, var(--bf-color-content-primary) 5%, transparent); +} + +.chat-page__composer-leading { + display: grid; + place-items: center; + flex: 0 0 40px; + width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: 20px; + background: transparent; + color: var(--bf-color-content-primary); +} + +.chat-page__input-wrap.is-expanded .chat-page__composer-leading { + display: none; +} + +.chat-page__input-wrap.is-expanded .chat-page__input-box { + min-height: 126px; + padding: 4px 8px 2px; + border: 1px solid var(--harmony-line); + border-radius: 18px; + background: var(--harmony-card); + box-shadow: 0 8px 24px color-mix(in srgb, var(--bf-color-content-primary) 9%, transparent); +} + +.chat-page__input-wrap.is-expanded .chat-page__input-area { + flex: 0 0 76px; + min-height: 76px; +} + +.chat-page__input-wrap.is-expanded .chat-page__input { + min-height: 74px; + max-height: 74px; + padding: 10px 4px 8px; +} + +.chat-page__input:focus, +.chat-page__input:focus-visible { + outline: 0; + outline-offset: 0; +} + +.chat-page__input-wrap.is-expanded .chat-page__input-actions { + height: 44px; + min-height: 44px; + padding: 0 0 0 2px; + align-items: center; +} + +.chat-page__input-wrap.is-expanded .chat-page__input-actions-right { + align-items: center; +} + +.chat-page__input-wrap.is-expanded .chat-page__action-btn { + width: 40px; + height: 40px; + color: var(--bf-color-content-primary); +} + +.chat-page__input-placeholder { + color: var(--bf-color-content-secondary); + font-size: 16px; + opacity: 1; +} + +.chat-page__send-btn { + width: 40px; + height: 40px; + border-radius: 20px; +} + +.chat-page__input-wrap:not(.is-expanded) .chat-page__send-btn { + opacity: 1; + background: transparent; + color: var(--bf-color-content-primary); +} + +.chat-page__send-btn.is-stop { + width: 40px; + height: 40px; + border-radius: 20px; +} + +.chat-page__send-btn.is-stop:focus-visible { + outline: 2px solid var(--bf-color-focus-ring); + outline-offset: 2px; +} + +.chat-page__scroll-to-bottom { + bottom: 88px; + background: var(--harmony-card); +} + +@media (min-width: 760px) { + .qr-scanner-sheet__backdrop { + align-items: center; + padding: 24px; + } + + .qr-scanner-sheet { + height: min(780px, calc(100dvh - 48px)); + border: 1px solid var(--harmony-line); + border-radius: 28px; + } + + .harmony-sidebar__sheet-backdrop { + align-items: center; + justify-content: center; + padding: 24px; + } + + .harmony-sidebar__settings-sheet { + width: min(520px, 100%); + height: min(760px, calc(100dvh - 48px)); + border: 1px solid var(--harmony-line); + border-radius: 28px; + box-shadow: 0 18px 60px var(--bf-color-overlay-scrim); + } + + .pairing-page { + align-items: center; + padding: 32px; + background: var(--harmony-page); + } + + .pairing-page__shell { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(380px, 0.92fr); + width: min(100%, 1040px); + min-height: min(700px, calc(100dvh - 64px)); + margin: auto; + border: 1px solid var(--harmony-line); + border-radius: 32px; + background: var(--harmony-card); + box-shadow: 0 18px 60px var(--bf-color-overlay-scrim); + } + + .pairing-page__hero { + display: flex; + padding: 56px 48px 42px; + } + + .pairing-page__hero-copy h2 { + max-width: 440px; + margin: 16px 0 18px; + color: var(--bf-color-content-primary); + font-size: clamp(36px, 4vw, 54px); + font-weight: 700; + line-height: 1.08; + letter-spacing: -0.045em; + } + + .pairing-page__panel { + min-height: 0; + border-radius: 0 32px 32px 0; + background: var(--harmony-page); + } + + .pairing-page__form { + padding: 0; + } + + .pairing-page__form-content { + padding-right: 56px; + padding-left: 56px; + } + + .pairing-page__action { + padding-right: 56px; + padding-bottom: 48px; + padding-left: 56px; + } + + .pairing-page__retry { + margin-top: auto; + } + + .remote-shell__master { + flex-basis: 360px; + width: 360px; + padding: 12px; + border: 0; + background: var(--harmony-page); + } + + .remote-shell__master .session-list { + overflow: hidden; + border: 1px solid var(--harmony-line); + border-radius: 28px; + background: var(--harmony-page); + } + + .remote-shell__master .harmony-sidebar { + overflow: hidden; + border: 1px solid var(--harmony-line); + border-radius: 28px; + background: var(--harmony-page); + } + + .remote-shell__detail { + margin: 12px 12px 12px 0; + overflow: hidden; + border: 1px solid var(--harmony-line); + border-radius: 28px; + background: var(--harmony-page); + } +} + +@media (min-width: 900px) { + .remote-shell__detail .remote-home__composer-wrap, + .chat-page--wide .chat-page__input-wrap { + width: min(100%, 760px); + max-width: 760px; + margin-right: auto; + margin-left: auto; + padding-right: 24px; + padding-left: 24px; + } + + .chat-page--wide .chat-page__messages { + padding-right: max(28px, calc((100% - 760px) / 2)); + padding-left: max(28px, calc((100% - 760px) / 2)); + } +} + +@media (max-width: 420px) { + .remote-home__header { + grid-template-columns: 42px minmax(0, 1fr) 42px; + height: 72px; + min-height: 72px; + padding-right: 12px; + padding-left: 12px; + } + + .remote-home__menu { + width: 42px; + height: 42px; + } + + .remote-home__content { + padding-right: 24px; + padding-left: 24px; + } + + .remote-home__composer-wrap { + padding-right: max(32px, env(safe-area-inset-right, 0px)); + padding-left: max(32px, env(safe-area-inset-left, 0px)); + } + + .session-list__language-btn, + .session-list__theme-btn { + display: none !important; + } + +} + +@media (prefers-reduced-motion: reduce) { + .chat-page__stop-spinner { + animation: none; + } +} diff --git a/src/mobile-web/src/styles/components/pairing.scss b/src/mobile-web/src/styles/components/pairing.scss index ab7df2b416..b0d1ce130f 100644 --- a/src/mobile-web/src/styles/components/pairing.scss +++ b/src/mobile-web/src/styles/components/pairing.scss @@ -2,28 +2,27 @@ .pairing-page { position: relative; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + display: grid; min-height: 100%; min-height: 100dvh; - gap: var(--size-gap-6); padding: - max(var(--size-gap-8), env(safe-area-inset-top, 0px)) - max(var(--size-gap-8), env(safe-area-inset-right, 0px)) - max(var(--size-gap-8), env(safe-area-inset-bottom, 0px)) - max(var(--size-gap-8), env(safe-area-inset-left, 0px)); + max(24px, env(safe-area-inset-top, 0px)) + max(20px, env(safe-area-inset-right, 0px)) + max(24px, env(safe-area-inset-bottom, 0px)) + max(20px, env(safe-area-inset-left, 0px)); + background: var(--bf-color-surface-canvas); + overflow-y: auto; animation: fadeIn var(--motion-slow) motion.$easing-decelerate; } .pairing-page__actions { - position: absolute; - top: calc(var(--size-gap-4) + env(safe-area-inset-top, 0px)); - right: calc(var(--size-gap-4) + env(safe-area-inset-right, 0px)); + position: fixed; + top: calc(16px + env(safe-area-inset-top, 0px)); + right: calc(16px + env(safe-area-inset-right, 0px)); + z-index: 10; display: flex; align-items: center; - gap: var(--size-gap-2); + gap: 8px; } .pairing-page__theme-btn { @@ -35,49 +34,259 @@ padding: 0; border: 1px solid var(--bf-color-border-subtle); border-radius: 50%; - background: var(--bf-color-surface-subtle); + background: var(--bf-color-surface-panel); color: var(--bf-color-content-secondary); + box-shadow: var(--bf-shadow-sm); cursor: pointer; - transition: all var(--motion-fast) var(--easing-standard); + transition: transform var(--motion-fast) var(--easing-standard), + background var(--motion-fast) var(--easing-standard), + color var(--motion-fast) var(--easing-standard); - &:active { - transform: scale(0.96); + &:hover { color: var(--bf-color-content-primary); - background: var(--bf-color-action-neutral-surface-hover); + background: var(--bf-color-surface-tertiary); } + + &:active { transform: scale(0.96); } } -.pairing-page__logo { +.pairing-page__shell { + width: min(100%, 1080px); + min-height: min(680px, calc(100dvh - 48px)); + margin: auto; + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr); + overflow: hidden; + border: 1px solid var(--bf-color-border-subtle); + border-radius: 32px; + background: var(--bf-color-surface-panel); + box-shadow: 0 28px 80px color-mix(in srgb, var(--bf-color-content-primary) 10%, transparent); +} + +.pairing-page__hero { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 100%; + padding: 56px 52px 42px; + overflow: hidden; + background: + radial-gradient(circle at 18% 16%, color-mix(in srgb, var(--bf-color-accent-secondary-border) 70%, transparent), transparent 38%), + linear-gradient(145deg, var(--bf-color-accent-surface), var(--bf-color-surface-tertiary)); + + &::after { + content: ''; + position: absolute; + width: 360px; + height: 360px; + right: -150px; + bottom: -170px; + border: 1px solid color-mix(in srgb, var(--bf-color-content-primary) 10%, transparent); + border-radius: 50%; + box-shadow: + 0 0 0 54px color-mix(in srgb, var(--bf-color-content-primary) 3%, transparent), + 0 0 0 108px color-mix(in srgb, var(--bf-color-content-primary) 2%, transparent); + } +} + +.pairing-page__hero-copy { + position: relative; + z-index: 1; + max-width: 470px; + + h1 { + max-width: 440px; + margin: 16px 0 18px; + font-family: var(--bf-font-family-sans); + font-size: clamp(38px, 4.2vw, 58px); + font-weight: 650; + line-height: 1.08; + letter-spacing: -0.045em; + color: var(--bf-color-content-primary); + } + + p { + max-width: 410px; + font-size: 16px; + line-height: 1.65; + color: var(--bf-color-content-secondary); + } +} + +.pairing-page__eyebrow { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 10px; + border: 1px solid var(--bf-color-border-default); + border-radius: 999px; + color: var(--bf-color-content-secondary); + font-size: 11px; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.12em; + text-transform: uppercase; + background: color-mix(in srgb, var(--bf-color-surface-panel) 66%, transparent); +} + +.pairing-page__connection-visual { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 190px; + margin: 26px 0 30px; +} + +.pairing-page__device { + position: relative; + flex: 0 0 auto; + color: var(--bf-color-content-primary); +} + +.pairing-page__device-screen { + display: block; + border: 2px solid currentColor; + background: var(--bf-color-surface-panel); + box-shadow: inset 0 0 0 6px var(--bf-color-surface-tertiary), var(--bf-shadow-lg); +} + +.pairing-page__device--desktop { + width: 178px; + + .pairing-page__device-screen { + width: 178px; + height: 112px; + border-radius: 14px; + } +} + +.pairing-page__device-base { + display: block; + width: 76px; + height: 12px; + margin: 7px auto 0; + border: solid currentColor; + border-width: 0 8px 2px; + border-radius: 0 0 8px 8px; + opacity: 0.72; +} + +.pairing-page__device--phone { width: 64px; - height: 64px; - object-fit: contain; - margin-bottom: var(--size-gap-2); - animation: pairingLogoBreathe 3.2s var(--easing-standard) infinite; + + .pairing-page__device-screen { + width: 64px; + height: 122px; + border-radius: 17px; + box-shadow: inset 0 0 0 5px var(--bf-color-surface-tertiary), var(--bf-shadow-lg); + } +} + +.pairing-page__connection-line { + position: relative; + width: clamp(58px, 7vw, 92px); + height: 2px; + margin: 0 18px; + background: var(--bf-color-border-strong); + + i { + position: absolute; + top: 50%; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--bf-color-content-primary); + transform: translate(-50%, -50%); + animation: pairingSignal 2.1s ease-in-out infinite; + + &:nth-child(1) { left: 10%; } + &:nth-child(2) { left: 50%; animation-delay: 0.18s; } + &:nth-child(3) { left: 90%; animation-delay: 0.36s; } + } +} + +@keyframes pairingSignal { + 0%, 55%, 100% { opacity: 0.25; transform: translate(-50%, -50%) scale(0.75); } + 25% { opacity: 1; transform: translate(-50%, -50%) scale(1); } } -@keyframes pairingLogoBreathe { - 0%, 100% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.05); opacity: 0.88; } +.pairing-page__security-note { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--bf-color-content-secondary); +} + +.pairing-page__security-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--bf-color-status-success-content); + box-shadow: 0 0 0 4px var(--bf-color-status-success-surface); +} + +.pairing-page__panel { + display: flex; + flex-direction: column; + justify-content: center; + padding: 64px clamp(34px, 5vw, 68px); + background: var(--bf-color-surface-panel); +} + +.pairing-page__identity { + display: grid; + grid-template-columns: 52px minmax(0, 1fr) 28px; + align-items: center; + gap: 14px; + margin-bottom: 34px; +} + +.pairing-page__logo { + width: 52px; + height: 52px; + object-fit: contain; } .pairing-page__brand { - font-size: var(--font-size-2xl); + font-family: var(--bf-font-family-sans); + font-size: 21px; + line-height: 1.25; font-weight: var(--font-weight-semibold); - color: var(--bf-color-accent-default); - letter-spacing: 0.5px; + color: var(--bf-color-content-primary); + letter-spacing: -0.02em; } .pairing-page__state { - font-size: var(--font-size-sm); + margin-top: 3px; + font-size: 13px; + line-height: 1.4; color: var(--bf-color-content-muted); - text-align: center; +} + +.pairing-page__spinner-wrap { + width: 28px; + min-height: 28px; + display: flex; + align-items: center; + justify-content: center; + + .spinner { + width: 22px; + height: 22px; + border-width: 2px; + } } .pairing-page__form { - width: min(100%, 320px); + width: 100%; display: flex; flex-direction: column; - gap: var(--size-gap-3); + gap: 18px; } .pairing-page__field { @@ -87,79 +296,142 @@ } .pairing-page__field-label { - font-size: var(--font-size-xs); + font-size: 12px; color: var(--bf-color-content-secondary); - letter-spacing: 0.04em; - text-transform: uppercase; + font-weight: var(--font-weight-medium); } .pairing-page__input { width: 100%; - min-height: 44px; - padding: 0 14px; + min-height: 50px; + padding: 0 16px; border: 1px solid var(--bf-color-border-default); - border-radius: var(--size-radius-base); - background: var(--bf-color-surface-raised); + border-radius: 15px; + background: var(--bf-color-surface-canvas); color: var(--bf-color-content-primary); - font-size: var(--font-size-base); + font-family: inherit; + font-size: 15px; outline: none; + transition: border-color var(--motion-fast), box-shadow var(--motion-fast), background var(--motion-fast); + + &::placeholder { color: var(--bf-color-content-muted); } + &:hover { background: var(--bf-color-surface-tertiary); } &:focus { - border-color: var(--bf-color-accent-border); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--bf-color-accent-border) 18%, transparent); + border-color: var(--bf-color-content-primary); + background: var(--bf-color-surface-panel); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--bf-color-content-primary) 10%, transparent); } } .pairing-page__note { - margin: 0; - font-size: var(--font-size-xs); - line-height: 1.5; + margin: -2px 0 0; + font-size: 12px; + line-height: 1.6; color: var(--bf-color-content-muted); - text-align: left; -} - -.pairing-page__spinner-wrap { - height: 32px; - display: flex; - align-items: center; - justify-content: center; } .pairing-page__error { - font-size: var(--font-size-xs); - color: var(--bf-color-status-danger-content); - text-align: center; - max-width: 280px; - padding: var(--size-gap-2) var(--size-gap-3); - background: var(--bf-color-status-danger-surface); + margin-top: 18px; + padding: 10px 12px; border: 1px solid var(--bf-color-status-danger-border); - border-radius: var(--size-radius-base); + border-radius: 12px; + background: var(--bf-color-status-danger-surface); + color: var(--bf-color-status-danger-content); + font-size: 12px; + line-height: 1.45; + text-align: left; } .pairing-page__retry { - padding: var(--size-gap-3) var(--size-gap-8); - background: var(--bf-color-action-secondary-background); - color: var(--bf-color-content-primary); - border: 1px solid var(--bf-color-border-default); - border-radius: var(--size-radius-base); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); + min-height: 50px; + padding: 0 24px; + border: 0; + border-radius: 15px; + background: var(--bf-color-action-primary-background); + color: var(--bf-color-action-primary-content); + font-family: inherit; + font-size: 14px; + font-weight: var(--font-weight-semibold); cursor: pointer; - transition: all var(--motion-fast) var(--easing-standard); + box-shadow: var(--bf-shadow-sm); + transition: transform var(--motion-fast), background var(--motion-fast), opacity var(--motion-fast); + + &:hover:not(:disabled) { background: var(--bf-color-action-primary-hover); } + &:active:not(:disabled) { transform: translateY(1px) scale(0.995); } + &:disabled { opacity: 0.5; cursor: not-allowed; } +} + +@media (max-width: 759px) { + .pairing-page { + display: block; + padding: + max(76px, calc(56px + env(safe-area-inset-top, 0px))) + max(18px, env(safe-area-inset-right, 0px)) + max(24px, env(safe-area-inset-bottom, 0px)) + max(18px, env(safe-area-inset-left, 0px)); + } - &:active { - background: var(--bf-color-action-secondary-hover); + .pairing-page__shell { + display: flex; + flex-direction: column; + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + overflow: visible; } - &:disabled { - opacity: 0.6; - cursor: not-allowed; + .pairing-page__hero { + min-height: 0; + padding: 12px 4px 24px; + background: transparent; + overflow: visible; + + &::after { display: none; } } + + .pairing-page__hero-copy { + h1 { + max-width: 340px; + margin: 12px 0 10px; + font-size: clamp(31px, 9.2vw, 40px); + line-height: 1.12; + } + + p { + max-width: 350px; + font-size: 14px; + line-height: 1.55; + } + } + + .pairing-page__connection-visual { display: none; } + .pairing-page__security-note { margin-top: 18px; } + + .pairing-page__panel { + margin-top: 8px; + padding: 24px 20px; + border: 1px solid var(--bf-color-border-subtle); + border-radius: 24px; + box-shadow: var(--bf-shadow-base); + } + + .pairing-page__identity { margin-bottom: 26px; } + .pairing-page__logo { width: 46px; height: 46px; } +} + +@media (max-width: 380px) { + .pairing-page__actions { top: calc(10px + env(safe-area-inset-top, 0px)); } + .pairing-page__panel { padding: 22px 16px; } + .pairing-page__identity { grid-template-columns: 44px minmax(0, 1fr) 24px; gap: 10px; } + .pairing-page__brand { font-size: 18px; } } @media (prefers-reduced-motion: reduce) { .pairing-page, - .pairing-page__logo { + .pairing-page__connection-line i { animation: none; } } diff --git a/src/mobile-web/src/styles/index.scss b/src/mobile-web/src/styles/index.scss index 70170a9f6f..f9fda46f83 100644 --- a/src/mobile-web/src/styles/index.scss +++ b/src/mobile-web/src/styles/index.scss @@ -9,3 +9,5 @@ @use './components/thinking'; @use './components/chat-input'; @use './components/markdown'; +@use './components/adaptive-shell'; +@use './components/harmony-native'; diff --git a/src/mobile-web/vite.config.ts b/src/mobile-web/vite.config.ts index 5d8e3d3fdb..90af24a4f4 100644 --- a/src/mobile-web/vite.config.ts +++ b/src/mobile-web/vite.config.ts @@ -9,6 +9,16 @@ export default defineConfig({ '@': path.resolve(__dirname, 'src'), }, }, + server: { + proxy: { + '/relay': { + target: 'https://remote.openbitfun.com', + changeOrigin: true, + secure: true, + ws: true, + }, + }, + }, build: { outDir: 'dist', assetsDir: 'assets',