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 && (