diff --git a/src/mobile-web/src/components/CompactSettingsSheet.tsx b/src/mobile-web/src/components/CompactSettingsSheet.tsx index 4529b92018..128eab26e6 100644 --- a/src/mobile-web/src/components/CompactSettingsSheet.tsx +++ b/src/mobile-web/src/components/CompactSettingsSheet.tsx @@ -32,6 +32,7 @@ interface CompactSettingsSheetProps { isDark: boolean; onClose: () => void; onDisconnectRequest: () => void; + onOpenDevices?: () => void; onSelectDevice: (device: SettingsDevice) => void; onToggleTheme: () => void; open: boolean; @@ -55,6 +56,7 @@ export default function CompactSettingsSheet({ isDark, onClose, onDisconnectRequest, + onOpenDevices, onSelectDevice, onToggleTheme, open, @@ -85,6 +87,12 @@ export default function CompactSettingsSheet({ {accountLabel && {t('settings.signedIn')}} + {onOpenDevices && ( + + {t('devices.title')} + + )} +

{t('settings.generalSection')}

diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index eaf0c59193..5cf409a0af 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -1739,6 +1739,7 @@ const SessionListPage: React.FC = ({ isDark={isDark} onClose={() => setCompactSettingsOpen(false)} onDisconnectRequest={() => { setCompactSettingsOpen(false); setShowDisconnectConfirm(true); }} + onOpenDevices={onOpenDevices ? () => { setCompactSettingsOpen(false); onOpenDevices(); } : undefined} onSelectDevice={(device) => void handleSelectCompactDevice(device)} onToggleTheme={toggleTheme} open={compactSettingsOpen} diff --git a/src/mobile-web/tests/helpers/browser-account-harness.mjs b/src/mobile-web/tests/helpers/browser-account-harness.mjs index c32b3683a0..e73c826a85 100644 --- a/src/mobile-web/tests/helpers/browser-account-harness.mjs +++ b/src/mobile-web/tests/helpers/browser-account-harness.mjs @@ -271,6 +271,10 @@ export async function connected(page, device = 'desktop-a') { export async function signOut(page) { await page.bringToFront(); // Open the device directory from the UI if this tab is controlling a host. + if (!await page.$('.devices-page') && await page.$('.harmony-sidebar__settings')) { + await page.click('button[aria-label="Settings"]'); + await page.waitForSelector('button[aria-label="Devices"]', { visible: true }); + } const button = await page.$('button[aria-label="Devices"]'); if (button) { await button.click(); await page.waitForSelector('.devices-page'); } const clicked = await page.evaluate(() => { diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 5898b59328..64ec1f6f46 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1805,7 +1805,7 @@ $_section-header-height: 22px; .openbitfun-device-overview__notice { width: calc(100% - var(--openbitfun-space-5) * 2); - margin: var(--openbitfun-space-4) var(--openbitfun-space-5) 0; + margin: 0 var(--openbitfun-space-5) var(--openbitfun-space-4); height: auto; min-height: var(--openbitfun-control-height-sm); justify-content: flex-start; @@ -1851,11 +1851,63 @@ $_section-header-height: 22px; } .openbitfun-device-overview__summary { + position: relative; + min-width: 0; + text-align: center; +} + +.openbitfun-device-overview__carousel-viewport { + overflow: hidden; + width: 100%; +} + +.openbitfun-device-overview__carousel-track { + display: flex; + transition: transform 220ms cubic-bezier(0.23, 1, 0.32, 1); +} + +.openbitfun-device-overview__carousel-slide { + flex: 0 0 100%; + min-width: 0; display: flex; flex-direction: column; align-items: center; +} + +.openbitfun-device-overview__device-switcher { + position: relative; + width: 100%; min-width: 0; - text-align: center; +} + +.openbitfun-device-overview__carousel-controls { + position: absolute; + inset: 0 0 auto; + aspect-ratio: 2 / 1; + display: flex; + align-items: center; + justify-content: space-between; + padding-inline: var(--openbitfun-space-4); + pointer-events: none; + + > button { pointer-events: auto; } +} + +@media (prefers-reduced-motion: reduce) { + .openbitfun-device-overview__carousel-track { transition: none; } +} + +.openbitfun-device-overview__connect-overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + pointer-events: none; + z-index: 1; + + > button { + pointer-events: auto; + } } .openbitfun-device-overview__artwork { @@ -1900,19 +1952,25 @@ $_section-header-height: 22px; font-family: var(--openbitfun-type-label-md-font-family); font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-body-sm-line-height); - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .openbitfun-device-overview__activity { + max-width: 100%; + box-sizing: border-box; padding: var(--openbitfun-space-1) var(--openbitfun-space-5) 0; color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-body-sm-line-height); - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .openbitfun-device-overview__device-group { - margin: var(--openbitfun-space-4) var(--openbitfun-space-5) 0; + margin: 0 var(--openbitfun-space-5) var(--openbitfun-space-4); h3 { margin: 0 6px 7px; diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx index 8aa7d205bd..275bd50589 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx @@ -98,7 +98,7 @@ describe('device status card', () => { expect(card.dataset.padding).toBe('none'); expect(card.querySelector('[data-openbitfun-part="header"]')?.getAttribute('data-content-align')).toBe('center'); expect(card.querySelector('[data-artwork="device"]')).not.toBeNull(); - expect(element('nav-device-status-summary').textContent).toBe('Workstation'); + expect(element('nav-device-status-summary').querySelector('.openbitfun-device-overview__device-name')?.textContent).toBe('Workstation'); expect(element('nav-device-status-manage').getAttribute('data-openbitfun-variant')).toBe('primary'); expect(card.querySelector('[data-testid="nav-device-status-connected-devices"]')).toBeNull(); act(() => element('nav-device-status-manage').click()); @@ -185,7 +185,7 @@ describe('device status card', () => { const name = 'Engineering workstation with a very long device name'; state.overview = overview({ localDeviceName: name }); render(); - expect(element('nav-device-status-summary').textContent).toBe(name); + expect(element('nav-device-status-summary').querySelector('.openbitfun-device-overview__device-name')?.textContent).toBe(name); expect(element('nav-device-status-summary').querySelector('[title]')?.getAttribute('title')).toBe(name); expect(element('nav-footer-device-status').getAttribute('aria-label')).toContain(name); }); diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index edc31905a5..399a4c4336 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -1,11 +1,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { OverflowText, Button, Card, CardBody, CardFooter, CardHeader, Icon, ScrollArea } from '@openbitfun/ui'; +import { OverflowText, Button, Card, CardBody, CardFooter, CardHeader, Icon, IconButton, ScrollArea } from '@openbitfun/ui'; import { createPortal } from 'react-dom'; -import { MessageCircle, Monitor, Server, Smartphone, Undo2 } from 'lucide-react'; +import { ChevronLeft, ChevronRight, MessageCircle, Monitor, Server, Smartphone, Undo2 } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { useAccountIdentity } from '@/infrastructure/account-identity'; +import { remoteConnectAPI, type AccountDeviceInfo } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { useNotification } from '@/shared/notification-system'; import { ChatAppBrandIcon, @@ -70,6 +73,72 @@ const DeviceStatusControl: React.FC = ({ const { t } = useI18n('common'); const { success, warning } = useNotification(); const peerContext = usePeerDeviceModeOptional(); + const identity = useAccountIdentity(); + const accountId = identity.status === 'signed-in' ? identity.me?.user.githubId : undefined; + const [switchTargets, setSwitchTargets] = useState<{ + accountId: number; localId: string; devices: AccountDeviceInfo[]; + } | null>(null); + const [switchingDevice, setSwitchingDevice] = useState(false); + const [returningLocal, setReturningLocal] = useState(false); + const switchingRef = useRef(false); + const activePeerId = peerContext?.peerMode.active ? peerContext.peerMode.deviceId : null; + const [previewId, setPreviewId] = useState(null); + useEffect(() => { setPreviewId(null); }, [open, accountId, activePeerId]); + + useEffect(() => { + if (!open || accountId === undefined) { setSwitchTargets(null); return; } + let disposed = false; + let generation = 0; + const load = async () => { + const request = ++generation; + try { + const [local, devices] = await Promise.all([ + remoteConnectAPI.getDeviceInfo(), remoteConnectAPI.accountListDevices(), + ]); + if (disposed || request !== generation) return; + setSwitchTargets({ accountId, localId: local.device_id, devices: [ + { ...local, online: true, last_seen_at: null }, + ...devices.filter(device => device.online && device.device_id !== local.device_id) + .sort((a, b) => a.device_id.localeCompare(b.device_id)), + ] }); + } catch (error) { + if (disposed || request !== generation) return; + setSwitchTargets(null); + warning(error instanceof Error ? error.message : String(error)); + } + }; + void load(); + const unlisten = api.listen('account://device-presence', () => { void load(); }); + return () => { disposed = true; unlisten(); }; + }, [accountId, open, warning]); + + const availableTargets = switchTargets?.accountId === accountId ? switchTargets : null; + const currentId = peerContext?.peerMode.active ? peerContext.peerMode.deviceId : availableTargets?.localId; + const previewTarget = availableTargets?.devices.find(device => device.device_id === previewId); + const isPreviewing = Boolean(previewTarget && previewTarget.device_id !== currentId); + const browseDevice = (direction: -1 | 1) => { + if (!availableTargets || switchingRef.current || returningLocal) return; + const index = availableTargets.devices.findIndex(device => device.device_id === (previewTarget?.device_id ?? currentId)); + const targetIndex = index < 0 + ? (direction === 1 ? 0 : availableTargets.devices.length - 1) + : (index + direction + availableTargets.devices.length) % availableTargets.devices.length; + setPreviewId(availableTargets.devices[targetIndex]?.device_id ?? null); + }; + const connectPreview = async () => { + if (!peerContext || !availableTargets || !previewTarget || !isPreviewing || switchingRef.current || returningLocal) return; + const target = previewTarget; + switchingRef.current = true; + setSwitchingDevice(true); + try { + if (target.device_id === availableTargets.localId) await peerContext.switchToLocal('manual'); + else await peerContext.switchToDevice(target.device_id, target.device_name); + } catch (error) { + warning(error instanceof Error ? error.message : String(error)); + } finally { + switchingRef.current = false; + setSwitchingDevice(false); + } + }; const platformHint = typeof navigator === 'undefined' ? '' : `${navigator.platform ?? ''} ${navigator.userAgent ?? ''}`; @@ -84,7 +153,15 @@ const DeviceStatusControl: React.FC = ({ overview, refresh, } = useDeviceInterconnectionOverview(localDeviceLabel, t('remoteConnect.mobileBrowserTitle')); - const [returningLocal, setReturningLocal] = useState(false); + const previewDevices: DeviceOverviewDevice[] = availableTargets?.devices.map(target => + target.device_id === currentId ? overview.primaryDevice + : overview.devices.find(device => device.id === target.device_id) ?? { + id: target.device_id, name: target.device_name, kind: 'desktop', + local: target.device_id === availableTargets.localId, activities: [], backgroundTaskCount: 0, + }) ?? [overview.primaryDevice]; + const previewIndex = Math.max(0, availableTargets?.devices.findIndex( + device => device.device_id === (previewTarget?.device_id ?? currentId), + ) ?? 0); const triggerRef = useRef(null); const popoverRef = useRef(null); const popoverLayout = useAnchoredPopoverPosition({ @@ -110,7 +187,7 @@ const DeviceStatusControl: React.FC = ({ }, [onOpenChange, open, refresh]); const handleReturnLocal = useCallback(async () => { - if (!peerContext?.peerMode.active || returningLocal) return; + if (!peerContext?.peerMode.active || returningLocal || switchingRef.current) return; setReturningLocal(true); try { const outcome = await peerContext.switchToLocal('manual'); @@ -260,24 +337,12 @@ const DeviceStatusControl: React.FC = ({ /> -
- - - {overview.currentWorkDeviceName} - - {overview.mode === 'connected' && ( - - {deviceActivity(overview.primaryDevice)} - - )} -
{overview.mode === 'connected' && ( <>
-

{t('deviceOverview.connectedDevices')}

{overview.connectedDevices.map(device => (
= ({
)} +
+
+
+ {previewDevices.map((device, index) => ( +
+
+ + {index === previewIndex && isPreviewing && ( +
+ +
+ )} +
+ + {device.name} + + + {index === previewIndex && !isPreviewing ? deviceActivity(overview.primaryDevice) : '\u00a0'} + +
+ ))} +
+
+
+ } + disabled={!peerContext || !availableTargets || availableTargets.devices.length < 2 || switchingDevice || returningLocal} + onClick={() => { browseDevice(-1); }} + /> + } + disabled={!peerContext || !availableTargets || availableTargets.devices.length < 2 || switchingDevice || returningLocal} + onClick={() => { browseDevice(1); }} + /> +
+
{overview.topologyUnavailable && ( {overview.peerActive && ( ; - return { OverflowText: Box, Alert: Box, Button, Icon: () => null, IconButton: () => null, ScrollArea: Box, StatusPill: Box }; + return { Avatar: ({ src, alt }: { src?: string; alt?: string }) => {alt}, OverflowText: Box, Alert: Box, Button, Icon: () => null, IconButton: () => null, ScrollArea: Box, StatusPill: Box }; }); let container: HTMLDivElement; let root: Root; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx index 5d65565464..8d9314ab3b 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx @@ -1,6 +1,6 @@ /** Account login and authenticated device connections. */ -import { OverflowText, Alert, Button, Icon, IconButton, ScrollArea, StatusPill } from '@openbitfun/ui'; +import { OverflowText, Alert, Avatar, Button, Icon, IconButton, ScrollArea, StatusPill } from '@openbitfun/ui'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { @@ -599,7 +599,7 @@ export const AccountPanel: React.FC = ({ {view === 'devices' && (
-