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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/mobile-web/src/components/CompactSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface CompactSettingsSheetProps {
isDark: boolean;
onClose: () => void;
onDisconnectRequest: () => void;
onOpenDevices?: () => void;
onSelectDevice: (device: SettingsDevice) => void;
onToggleTheme: () => void;
open: boolean;
Expand All @@ -55,6 +56,7 @@ export default function CompactSettingsSheet({
isDark,
onClose,
onDisconnectRequest,
onOpenDevices,
onSelectDevice,
onToggleTheme,
open,
Expand Down Expand Up @@ -85,6 +87,12 @@ export default function CompactSettingsSheet({
{accountLabel && <MobileBadge className="harmony-sidebar__verified" tone="success">{t('settings.signedIn')}</MobileBadge>}
</MobileCard>

{onOpenDevices && (
<MobileButton appearance="plain" block onClick={onOpenDevices} aria-label={t('devices.title')}>
{t('devices.title')}
</MobileButton>
)}

<h3>{t('settings.generalSection')}</h3>
<MobileCard padding="none" className="harmony-sidebar__settings-card">
<MobileButton appearance="plain" block className="harmony-sidebar__settings-row" role="switch" aria-checked={isDark} aria-label={t('settings.darkAppearance')} onClick={onToggleTheme}>
Expand Down
1 change: 1 addition & 0 deletions src/mobile-web/src/pages/SessionListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1739,6 +1739,7 @@ const SessionListPage: React.FC<SessionListPageProps> = ({
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}
Expand Down
4 changes: 4 additions & 0 deletions src/mobile-web/tests/helpers/browser-account-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
68 changes: 63 additions & 5 deletions src/web-ui/src/app/components/NavPanel/NavPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -70,6 +73,72 @@ const DeviceStatusControl: React.FC<DeviceStatusControlProps> = ({
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<string | null>(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 ?? ''}`;
Expand All @@ -84,7 +153,15 @@ const DeviceStatusControl: React.FC<DeviceStatusControlProps> = ({
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<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const popoverLayout = useAnchoredPopoverPosition({
Expand All @@ -110,7 +187,7 @@ const DeviceStatusControl: React.FC<DeviceStatusControlProps> = ({
}, [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');
Expand Down Expand Up @@ -260,24 +337,12 @@ const DeviceStatusControl: React.FC<DeviceStatusControlProps> = ({
/>
<ScrollArea className="openbitfun-device-overview__scroll">
<CardBody className="openbitfun-device-overview__body">
<div className="openbitfun-device-overview__summary" data-testid="nav-device-status-summary">
<DeviceArtwork device={overview.primaryDevice} />
<span className="openbitfun-device-overview__device-name" title={overview.currentWorkDeviceName}>
{overview.currentWorkDeviceName}
</span>
{overview.mode === 'connected' && (
<span className="openbitfun-device-overview__activity">
{deviceActivity(overview.primaryDevice)}
</span>
)}
</div>
{overview.mode === 'connected' && (
<>
<section
className="openbitfun-device-overview__device-group"
data-testid="nav-device-status-connected-devices"
>
<h3>{t('deviceOverview.connectedDevices')}</h3>
<div className="openbitfun-device-overview__device-rows">
{overview.connectedDevices.map(device => (
<div
Expand All @@ -301,6 +366,53 @@ const DeviceStatusControl: React.FC<DeviceStatusControlProps> = ({
</section>
</>
)}
<div className="openbitfun-device-overview__summary" data-testid="nav-device-status-summary">
<div className="openbitfun-device-overview__carousel-viewport">
<div className="openbitfun-device-overview__carousel-track"
style={{ transform: `translateX(-${previewIndex * 100}%)` }}>
{previewDevices.map((device, index) => (
<div className="openbitfun-device-overview__carousel-slide" key={availableTargets?.devices[index]?.device_id ?? device.id}
aria-hidden={index !== previewIndex}>
<div className="openbitfun-device-overview__device-switcher">
<DeviceArtwork device={device} />
{index === previewIndex && isPreviewing && (
<div className="openbitfun-device-overview__connect-overlay">
<Button variant="outline" size="sm" disabled={switchingDevice || returningLocal}
onClick={() => { void connectPreview(); }}>
{t('deviceOverview.connectDevice')}
</Button>
</div>
)}
</div>
<span className="openbitfun-device-overview__device-name" title={device.name}>
{device.name}
</span>
<span className="openbitfun-device-overview__activity">
{index === previewIndex && !isPreviewing ? deviceActivity(overview.primaryDevice) : '\u00a0'}
</span>
</div>
))}
</div>
</div>
<div className="openbitfun-device-overview__carousel-controls">
<IconButton
variant="quiet" size="xs" shape="circle"
aria-label={t('deviceOverview.previousDevice')}
title={t('deviceOverview.previousDevice')}
icon={<Icon glyph={ChevronLeft} size="sm" />}
disabled={!peerContext || !availableTargets || availableTargets.devices.length < 2 || switchingDevice || returningLocal}
onClick={() => { browseDevice(-1); }}
/>
<IconButton
variant="quiet" size="xs" shape="circle"
aria-label={t('deviceOverview.nextDevice')}
title={t('deviceOverview.nextDevice')}
icon={<Icon glyph={ChevronRight} size="sm" />}
disabled={!peerContext || !availableTargets || availableTargets.devices.length < 2 || switchingDevice || returningLocal}
onClick={() => { browseDevice(1); }}
/>
</div>
</div>

{overview.topologyUnavailable && (
<Button
Expand All @@ -325,7 +437,7 @@ const DeviceStatusControl: React.FC<DeviceStatusControlProps> = ({
onClick={handleManageDevices}
data-testid="nav-device-status-manage"
>
{t('accountLogin.connectDevices')}
{t('deviceOverview.devicesAndConnections')}
</Button>
{overview.peerActive && (
<Button
Expand All @@ -334,7 +446,7 @@ const DeviceStatusControl: React.FC<DeviceStatusControlProps> = ({
size="sm"
leadingIcon={<Icon glyph={Undo2} />}
onClick={() => { void handleReturnLocal(); }}
disabled={returningLocal}
disabled={returningLocal || switchingDevice}
data-testid="nav-device-status-return-local"
>
{returningLocal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
padding-bottom: var(--openbitfun-space-6);
color: var(--openbitfun-color-content-secondary);

> svg { flex-shrink: 0; }
> :first-child { flex-shrink: 0; }
}

&__identity-copy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ succe
vi.mock('@openbitfun/ui', () => {
const Box = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
const Button = ({ children, onClick, disabled }: { children?: React.ReactNode; onClick?: React.MouseEventHandler<HTMLButtonElement>; disabled?: boolean }) => <button onClick={onClick} disabled={disabled}>{children}</button>;
return { OverflowText: Box, Alert: Box, Button, Icon: () => null, IconButton: () => null, ScrollArea: Box, StatusPill: Box };
return { Avatar: ({ src, alt }: { src?: string; alt?: string }) => <img src={src} alt={alt} />, OverflowText: Box, Alert: Box, Button, Icon: () => null, IconButton: () => null, ScrollArea: Box, StatusPill: Box };
});
let container: HTMLDivElement;
let root: Root;
Expand Down
Loading
Loading