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
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,27 @@
background: var(--openbitfun-color-surface-tertiary);

&--compact {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);

.openbitfun-remote-connect__pairing-visual {
padding: var(--openbitfun-space-5) var(--openbitfun-space-5) 0;
border: 0;
}

.openbitfun-remote-connect__pairing-details {
align-items: center;
gap: var(--openbitfun-space-3);
text-align: center;
}

.openbitfun-remote-connect__pairing-status {
align-self: center;
margin-bottom: 0;
}

.openbitfun-remote-connect__hint {
margin: 0;
}
}
}

Expand All @@ -429,8 +449,9 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 11px;
padding: 22px;
gap: var(--openbitfun-space-3);
min-width: 0;
padding: var(--openbitfun-space-5);
border-right: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle);
background: transparent;
}
Expand Down Expand Up @@ -480,6 +501,13 @@
padding: var(--openbitfun-space-4) var(--openbitfun-space-5);
}

.openbitfun-remote-connect__pairing-status {
display: flex;
align-self: flex-start;
max-width: 100%;
margin-bottom: var(--openbitfun-space-4);
}

.openbitfun-remote-connect__pairing-label {
margin-bottom: 7px;
color: var(--openbitfun-color-content-muted);
Expand All @@ -493,7 +521,7 @@
gap: 10px;
padding-bottom: 18px;

code {
> span {
min-width: 0;
flex: 1;
overflow: hidden;
Expand Down Expand Up @@ -531,23 +559,19 @@
justify-content: flex-end;
}

.openbitfun-remote-connect__pairing-code-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}

.openbitfun-remote-connect__pairing-code {
padding: 14px 18px;
border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-default);
border-radius: var(--openbitfun-radius-lg);
background: var(--openbitfun-color-surface-panel);
max-width: 100%;
padding: var(--openbitfun-space-3) var(--openbitfun-space-5);
border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle);
border-radius: var(--openbitfun-radius-pill);
background: var(--openbitfun-color-surface-canvas);
color: var(--openbitfun-color-content-primary);
font-family: var(--openbitfun-type-body-sm-font-family);
font-size: var(--openbitfun-type-heading-dialog-font-size);
font-weight: var(--openbitfun-type-heading-page-font-weight);
letter-spacing: var(--openbitfun-type-modifier-tracking-expanded-letter-spacing);
font-size: var(--openbitfun-type-flow-section-title-font-size);
font-weight: var(--openbitfun-type-label-selected-font-weight);
line-height: var(--openbitfun-type-body-sm-line-height);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
user-select: all;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
Button,
Field,
Icon,
IconButton,
Input,
PageHeader,
ScrollArea,
Expand Down Expand Up @@ -65,6 +64,7 @@ import {
updateIfOperationCurrent,
} from './remoteConnectOperationCleanup';
import { ChatAppBrandIcon } from './ChatAppBrandIcon';
import { RemotePairingCard } from './RemotePairingCard';
import './RemoteConnectDialog.scss';

// ── Types ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -998,83 +998,13 @@ export const RemoteConnectDialog: React.FC<RemoteConnectDialogProps> = ({
data-openbitfun-part="body"
className="openbitfun-remote-connect__body openbitfun-remote-connect__body--pairing"
>
<div
className={`openbitfun-remote-connect__pairing-card${connectionResult.qr_url ? '' : ' openbitfun-remote-connect__pairing-card--compact'}`}
data-openbitfun-component="remote-connect-dialog"
data-openbitfun-part="pairingCard"
>
{(connectionResult.qr_url || connectionResult.bot_pairing_code) && (
<div className="openbitfun-remote-connect__pairing-visual">
{connectionResult.qr_url && (
<button
type="button"
className="openbitfun-remote-connect__qr-box"
title={t('remoteConnect.copyUrl')}
aria-label={t('remoteConnect.copyUrl')}
onClick={() => void handleCopyPairingUrl()}
>
<QRCodeSVG value={connectionResult.qr_url} size={180} level="M" includeMargin />
</button>
)}
{connectionResult.bot_pairing_code && (
<div className="openbitfun-remote-connect__pairing-code-box">
<div className="openbitfun-remote-connect__pairing-code">
{connectionResult.bot_pairing_code}
</div>
</div>
)}
<StatusPill tone={qrCopied ? 'success' : 'warning'}>
{qrCopied
? t('remoteConnect.urlCopied')
: connectionOwner === 'bot'
? t('remoteConnect.stateWaitingBot')
: t('remoteConnect.stateWaiting')}
</StatusPill>
</div>
)}
<div className="openbitfun-remote-connect__pairing-details">
{connectionResult.qr_url && (
<>
<span className="openbitfun-remote-connect__pairing-label">
{t('remoteConnect.workspaceAddress')}
</span>
<div className="openbitfun-remote-connect__pairing-url-row">
<code title={connectionResult.qr_url}>{connectionResult.qr_url}</code>
<IconButton
aria-label={t('remoteConnect.copyUrl')}
title={t('remoteConnect.copyUrl')}
icon={qrCopied ? <Icon name="check-line" size="lg" /> : <Icon name="duplicate" size="lg" />}
onClick={() => void handleCopyPairingUrl()}
size="sm"
variant="quiet"
/>
</div>
<div className="openbitfun-remote-connect__pairing-instruction">
<Smartphone size={19} aria-hidden="true" />
<p>{t('remoteConnect.scanHint')}</p>
</div>
<div className="openbitfun-remote-connect__pairing-instruction">
<Icon name="browser" size="lg" aria-hidden="true" />
<p>{t('remoteConnect.mobileBrowserDescription')}</p>
</div>
</>
)}
{!connectionResult.qr_url && (
<>
<StatusPill tone="warning">
{connectionOwner === 'bot'
? t('remoteConnect.stateWaitingBot')
: t('remoteConnect.stateWaiting')}
</StatusPill>
<p className="openbitfun-remote-connect__hint">
{connectionOwner === 'bot'
? t('remoteConnect.botHint')
: t('remoteConnect.stateWaiting')}
</p>
</>
)}
</div>
</div>
<RemotePairingCard
qrUrl={connectionResult.qr_url}
pairingCode={connectionResult.bot_pairing_code}
owner={connectionOwner === 'bot' ? 'bot' : 'network'}
copied={qrCopied}
onCopyUrl={handleCopyPairingUrl}
/>
<div className="openbitfun-remote-connect__pairing-actions">
<Button variant="outline" size="sm" onClick={handleCancelConnect}>
{t('remoteConnect.cancel')}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { RemotePairingCard } from './RemotePairingCard';

vi.mock('@/infrastructure/i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}));

function renderCard(props: Partial<React.ComponentProps<typeof RemotePairingCard>> = {}) {
const container = document.createElement('div');
container.innerHTML = renderToStaticMarkup(
<RemotePairingCard owner="bot" copied={false} onCopyUrl={() => {}} {...props} />,
);
return container;
}

describe('RemotePairingCard pending states', () => {
it.each(['bot', 'network'] as const)('renders one waiting status for a %s pairing code', owner => {
const view = renderCard({ owner, pairingCode: '123456' });
expect(view.querySelectorAll('[role="status"]')).toHaveLength(1);
expect(view.querySelectorAll('[data-openbitfun-component="status-pill"]')).toHaveLength(1);
expect(view.querySelector('.openbitfun-remote-connect__pairing-code')?.textContent).toBe('123456');
expect(view.textContent?.match(/remoteConnect.stateWaiting(?:Bot)?/g)).toHaveLength(1);
});

it.each(['bot', 'network'] as const)('restores %s waiting state without instructions for a missing code', owner => {
const view = renderCard({ owner });
expect(view.querySelectorAll('[role="status"]')).toHaveLength(1);
expect(view.querySelector('.openbitfun-remote-connect__pairing-visual')).toBeNull();
expect(view.textContent).not.toContain('remoteConnect.botHint');
expect(view.textContent).not.toContain('remoteConnect.scanHint');
});

it('retains the QR link, copy controls and instructions with a single status', () => {
const view = renderCard({ owner: 'network', qrUrl: 'https://example.test/pair' });
expect(view.querySelector('svg')).not.toBeNull();
expect(view.textContent).toContain('https://example.test/pair');
expect(view.textContent).toContain('remoteConnect.scanHint');
expect(view.querySelectorAll('[role="status"]')).toHaveLength(1);
expect(view.querySelectorAll('button[aria-label="remoteConnect.copyUrl"]')).toHaveLength(2);
});

it('shows copy feedback only while a URL is present', () => {
const copied = renderCard({ qrUrl: 'https://example.test/pair', copied: true });
expect(copied.querySelector('[role="status"]')?.textContent).toBe('remoteConnect.urlCopied');
expect(copied.textContent).not.toContain('remoteConnect.stateWaiting');
const codeOnly = renderCard({ pairingCode: '123456', copied: true });
expect(codeOnly.querySelector('[role="status"]')?.textContent).toBe('remoteConnect.stateWaitingBot');
});

it('keeps one status when a provider returns both a URL and a pairing code', () => {
const view = renderCard({ pairingCode: '123456', qrUrl: 'https://example.test/pair' });
expect(view.querySelector('.openbitfun-remote-connect__pairing-code')).not.toBeNull();
expect(view.querySelectorAll('[role="status"]')).toHaveLength(1);
});

it('delegates both QR and copy-button clicks without initiating a connection', async () => {
const onCopyUrl = vi.fn();
const container = document.createElement('div');
const root = createRoot(container);
try {
await act(async () => {
root.render(<RemotePairingCard owner="network" copied={false} qrUrl="https://example.test/pair" onCopyUrl={onCopyUrl} />);
});
for (const button of container.querySelectorAll('button')) {
await act(async () => { button.click(); });
}
expect(onCopyUrl).toHaveBeenCalledTimes(2);
} finally {
await act(async () => root.unmount());
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Icon, IconButton, StatusPill } from '@openbitfun/ui';
import { QRCodeSVG } from 'qrcode.react';
import { useI18n } from '@/infrastructure/i18n';

interface RemotePairingCardProps {
qrUrl?: string | null;
pairingCode?: string | null;
owner: 'bot' | 'network';
copied: boolean;
onCopyUrl: () => void | Promise<void>;
}

/** One shared pending state for chat-app codes, network QR links, and restored connections. */
export function RemotePairingCard({ qrUrl, pairingCode, owner, copied, onCopyUrl }: RemotePairingCardProps) {
const { t } = useI18n('common');
const hasCopiedUrl = Boolean(qrUrl && copied);

return (
<div
className={`openbitfun-remote-connect__pairing-card${qrUrl ? '' : ' openbitfun-remote-connect__pairing-card--compact'}`}
data-openbitfun-component="remote-connect-dialog"
data-openbitfun-part="pairingCard"
>
{(qrUrl || pairingCode) && (
<div className="openbitfun-remote-connect__pairing-visual">
{qrUrl && (
<button
type="button"
className="openbitfun-remote-connect__qr-box"
title={t('remoteConnect.copyUrl')}
aria-label={t('remoteConnect.copyUrl')}
onClick={() => void onCopyUrl()}
>
<QRCodeSVG value={qrUrl} size={180} level="M" includeMargin />
</button>
)}
{pairingCode && (
<div className="openbitfun-remote-connect__pairing-code" dir="ltr">
{pairingCode}
</div>
)}
</div>
)}
<div className="openbitfun-remote-connect__pairing-details">
<div className="openbitfun-remote-connect__pairing-status" role="status">
<StatusPill tone={hasCopiedUrl ? 'success' : 'warning'}>
{hasCopiedUrl
? t('remoteConnect.urlCopied')
: owner === 'bot'
? t('remoteConnect.stateWaitingBot')
: t('remoteConnect.stateWaiting')}
</StatusPill>
</div>
{qrUrl ? (
<>
<span className="openbitfun-remote-connect__pairing-label">
{t('remoteConnect.workspaceAddress')}
</span>
<div className="openbitfun-remote-connect__pairing-url-row">
<span title={qrUrl}>{qrUrl}</span>
<IconButton
aria-label={t('remoteConnect.copyUrl')}
title={t('remoteConnect.copyUrl')}
icon={<Icon name={hasCopiedUrl ? 'check-line' : 'duplicate'} size="lg" />}
onClick={() => void onCopyUrl()}
size="sm"
variant="quiet"
/>
</div>
<div className="openbitfun-remote-connect__pairing-instruction">
<Icon name="browser" size="lg" aria-hidden="true" />
<p>{t('remoteConnect.scanHint')}</p>
</div>
<div className="openbitfun-remote-connect__pairing-instruction">
<Icon name="link" size="lg" aria-hidden="true" />
<p>{t('remoteConnect.mobileBrowserDescription')}</p>
</div>
</>
) : owner === 'bot' && pairingCode ? (
<p className="openbitfun-remote-connect__hint">{t('remoteConnect.botHint')}</p>
) : null}
</div>
</div>
);
}
Loading