diff --git a/src/App.tsx b/src/App.tsx
index d868c047..6ac5c4fd 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -53,6 +53,7 @@ import ContextMenu from './components/ContextMenu';
import SongInfoModal from './components/SongInfoModal';
import DownloadFolderModal from './components/DownloadFolderModal';
import GlobalConfirmModal from './components/GlobalConfirmModal';
+import OrbitAccountPicker from './components/OrbitAccountPicker';
import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal';
import OverlayScrollArea from './components/OverlayScrollArea';
@@ -520,6 +521,7 @@ function AppShell() {
+
diff --git a/src/components/OrbitAccountPicker.tsx b/src/components/OrbitAccountPicker.tsx
new file mode 100644
index 00000000..f467e547
--- /dev/null
+++ b/src/components/OrbitAccountPicker.tsx
@@ -0,0 +1,69 @@
+import { useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import { X, User } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
+
+/**
+ * Modal shown when joining an Orbit session and the user has more than
+ * one account for the target server URL. Lets them pick which account
+ * to switch to before the join flow continues. Mount once in App.tsx —
+ * any caller can invoke it via `useOrbitAccountPickerStore.request(...)`.
+ */
+export default function OrbitAccountPicker() {
+ const { t } = useTranslation();
+ const { isOpen, accounts, pick, cancel } = useOrbitAccountPickerStore();
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') cancel(); };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [isOpen, cancel]);
+
+ if (!isOpen) return null;
+
+ return createPortal(
+
{ if (e.target === e.currentTarget) cancel(); }}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="orbit-account-picker-title"
+ style={{ alignItems: 'center', paddingTop: 0 }}
+ >
+
+
+
+
+
+ {t('orbit.accountPickerTitle')}
+
+
+ {t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })}
+
+
+ {accounts.map(a => (
+
+ pick(a)}
+ >
+
+ {a.username}
+ {a.name && · {a.name} }
+
+
+ ))}
+
+
+
+ {t('orbit.btnCancel')}
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/src/components/OrbitJoinModal.tsx b/src/components/OrbitJoinModal.tsx
index eacb3f64..c20a777f 100644
--- a/src/components/OrbitJoinModal.tsx
+++ b/src/components/OrbitJoinModal.tsx
@@ -10,6 +10,7 @@ import {
joinOrbitSession,
} from '../utils/orbit';
import { switchActiveServer } from '../utils/switchActiveServer';
+import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
import { showToast } from '../utils/toast';
interface Props {
@@ -49,15 +50,20 @@ export default function OrbitJoinModal({ onClose }: Props) {
setBusy(true);
try {
// Auto-switch to the link's server if the user has an account for it.
- // switch itself tears down any lingering orbit session.
+ // Multiple candidates → picker modal. switch tears down any lingering
+ // orbit session.
if (activeUrl !== wantUrl) {
- const targetServer = useAuthStore.getState().servers
- .find(s => s.url.replace(/\/+$/, '') === wantUrl);
- if (!targetServer) {
+ const candidates = useAuthStore.getState().servers
+ .filter(s => s.url.replace(/\/+$/, '') === wantUrl);
+ if (candidates.length === 0) {
setError(t('orbit.toastNoAccountForServer', { url: wantUrl }));
return;
}
- const switched = await switchActiveServer(targetServer);
+ const target = candidates.length === 1
+ ? candidates[0]
+ : await useOrbitAccountPickerStore.getState().request(candidates);
+ if (!target) { setBusy(false); return; }
+ const switched = await switchActiveServer(target);
if (!switched) {
setError(t('orbit.toastSwitchFailed', { url: wantUrl }));
return;
diff --git a/src/components/PasteClipboardHandler.tsx b/src/components/PasteClipboardHandler.tsx
index c580f2ff..7bd4e7c3 100644
--- a/src/components/PasteClipboardHandler.tsx
+++ b/src/components/PasteClipboardHandler.tsx
@@ -14,6 +14,7 @@ import {
OrbitJoinError,
} from '../utils/orbit';
import { switchActiveServer } from '../utils/switchActiveServer';
+import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
import ConfirmModal from './ConfirmModal';
const ORBIT_JOIN_ERROR_KEYS: Record = {
@@ -91,17 +92,22 @@ export default function PasteClipboardHandler() {
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
// Auto-switch to the link's target server if the user has an
- // account registered for it. No account → clear error. switch
- // itself tears down any lingering orbit session (see
+ // account registered for it. No account → clear error. Multiple
+ // accounts for the same URL → picker lets the user choose. The
+ // switch itself tears down any lingering orbit session (see
// switchActiveServer) so the join below starts clean.
if (activeUrl !== wantUrl) {
- const targetServer = useAuthStore.getState().servers
- .find(s => s.url.replace(/\/+$/, '') === wantUrl);
- if (!targetServer) {
+ const candidates = useAuthStore.getState().servers
+ .filter(s => s.url.replace(/\/+$/, '') === wantUrl);
+ if (candidates.length === 0) {
showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning');
return;
}
- const switched = await switchActiveServer(targetServer);
+ const target = candidates.length === 1
+ ? candidates[0]
+ : await useOrbitAccountPickerStore.getState().request(candidates);
+ if (!target) return; // cancelled
+ const switched = await switchActiveServer(target);
if (!switched) {
showToast(t('orbit.toastSwitchFailed', { url: wantUrl }), 5000, 'error');
return;
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 58b855f3..6ae4a470 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -1548,6 +1548,8 @@ export const deTranslation = {
toastSwitchServer: 'Wechsle erst zu {{url}} und füge den Link dann erneut ein',
toastNoAccountForServer: 'Du hast keinen Zugang zu {{url}}. Bitte den Host um einen Einladungslink.',
toastSwitchFailed: 'Wechsel zu {{url}} fehlgeschlagen',
+ accountPickerTitle: 'Welcher Account?',
+ accountPickerSub: 'Du hast mehrere Accounts für {{url}}. Wähle aus, mit welchem du der Session beitreten willst.',
toastJoinFail: 'Session konnte nicht beigetreten werden',
joinErrNotFound: 'Session nicht gefunden',
joinErrEnded: 'Session wurde beendet',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 54fe3da5..9818af0f 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -1551,6 +1551,8 @@ export const enTranslation = {
toastSwitchServer: 'Switch to {{url}} first, then paste again',
toastNoAccountForServer: "You don't have access to {{url}}. Ask the host for an invite.",
toastSwitchFailed: "Couldn't switch to {{url}}",
+ accountPickerTitle: 'Which account?',
+ accountPickerSub: 'You have more than one account for {{url}}. Pick the one to join the session as.',
toastJoinFail: "Couldn't join session",
joinErrNotFound: 'Session not found',
joinErrEnded: 'Session has ended',
diff --git a/src/store/orbitAccountPickerStore.ts b/src/store/orbitAccountPickerStore.ts
new file mode 100644
index 00000000..74675dfa
--- /dev/null
+++ b/src/store/orbitAccountPickerStore.ts
@@ -0,0 +1,39 @@
+import { create } from 'zustand';
+import type { ServerProfile } from './authStore';
+
+let _resolve: ((server: ServerProfile | null) => void) | null = null;
+
+interface OrbitAccountPickerStore {
+ isOpen: boolean;
+ accounts: ServerProfile[];
+ /** Open the picker with the given candidates. Resolves with the chosen
+ * server or null if the user cancels. */
+ request: (accounts: ServerProfile[]) => Promise;
+ pick: (server: ServerProfile) => void;
+ cancel: () => void;
+}
+
+export const useOrbitAccountPickerStore = create(set => ({
+ isOpen: false,
+ accounts: [],
+
+ request: (accounts) =>
+ new Promise(resolve => {
+ // If another picker is already pending, treat the previous one as cancelled.
+ if (_resolve) _resolve(null);
+ _resolve = resolve;
+ set({ isOpen: true, accounts });
+ }),
+
+ pick: (server) => {
+ _resolve?.(server);
+ _resolve = null;
+ set({ isOpen: false });
+ },
+
+ cancel: () => {
+ _resolve?.(null);
+ _resolve = null;
+ set({ isOpen: false });
+ },
+}));
diff --git a/src/styles/components.css b/src/styles/components.css
index 8ffc791a..d7934c99 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -11918,6 +11918,70 @@ html[data-psy-native-hidden="true"] *::after {
cursor: not-allowed;
}
+/* Account picker — modal for multi-account server switch on Orbit join. */
+.orbit-account-picker {
+ max-width: 420px;
+ width: min(420px, calc(100vw - 32px));
+ padding: 22px 26px 20px;
+ position: relative;
+}
+.orbit-account-picker__title {
+ margin: 0 0 6px;
+ padding-right: 1.5rem;
+ font-size: 17px;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: -0.01em;
+}
+.orbit-account-picker__sub {
+ margin: 0 0 14px;
+ font-size: 13px;
+ color: var(--text-muted);
+ line-height: 1.5;
+}
+.orbit-account-picker__list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.orbit-account-picker__item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 10px 12px;
+ font-size: 13px;
+ text-align: left;
+ background: color-mix(in srgb, var(--text-primary) 4%, transparent);
+ border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: background 120ms ease, border-color 120ms ease;
+}
+.orbit-account-picker__item svg {
+ color: var(--accent);
+ flex-shrink: 0;
+}
+.orbit-account-picker__item:hover {
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ border-color: color-mix(in srgb, var(--accent) 35%, transparent);
+}
+.orbit-account-picker__user {
+ font-weight: 600;
+}
+.orbit-account-picker__name {
+ color: var(--text-muted);
+ font-weight: 400;
+}
+.orbit-account-picker__actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
/* ── Start-session modal ────────────────────────────────────────── */
.orbit-start-overlay {
align-items: center;