From 05e78f0fa54cbb46a908b7f0173edd747fe8585b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 18:44:42 +0000 Subject: [PATCH] Support multiple remote desktop sessions as tabs with disconnect controls --- src/pages/RemoteDesktop.tsx | 162 +++++++++++++++++++++++++++--------- 1 file changed, 123 insertions(+), 39 deletions(-) diff --git a/src/pages/RemoteDesktop.tsx b/src/pages/RemoteDesktop.tsx index 9fd6836..be67e8b 100644 --- a/src/pages/RemoteDesktop.tsx +++ b/src/pages/RemoteDesktop.tsx @@ -4,13 +4,27 @@ import { api, getToken, type Integration } from '../lib/api' const TEXT_SECONDARY = '#7A7D85' +type SessionStatus = 'connecting' | 'connected' | 'error' + +interface Session { + sessionId: string + hostId: number + name: string + status: SessionStatus + errorMessage: string +} + +interface SessionHandle { + client: any + container: HTMLDivElement +} + export default function RemoteDesktop() { const [hosts, setHosts] = useState([]) - const [hostId, setHostId] = useState(null) - const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle') - const [errorMessage, setErrorMessage] = useState('') - const displayRef = useRef(null) - const clientRef = useRef(null) + const [sessions, setSessions] = useState([]) + const [activeSessionId, setActiveSessionId] = useState(null) + const displayHostRef = useRef(null) + const handlesRef = useRef>(new Map()) useEffect(() => { api.listIntegrations().then(({ integrations }) => { @@ -20,15 +34,22 @@ export default function RemoteDesktop() { useEffect(() => { return () => { - clientRef.current?.disconnect() + handlesRef.current.forEach(({ client }) => client.disconnect()) } }, []) - function connect(id: number) { - clientRef.current?.disconnect() - if (displayRef.current) displayRef.current.innerHTML = '' - setStatus('connecting') - setErrorMessage('') + function patchSession(sessionId: string, patch: Partial) { + setSessions((prev) => prev.map((s) => (s.sessionId === sessionId ? { ...s, ...patch } : s))) + } + + function openSession(host: Integration) { + const sessionId = crypto.randomUUID() + const session: Session = { sessionId, hostId: host.id, name: host.name, status: 'connecting', errorMessage: '' } + setSessions((prev) => [...prev, session]) + setActiveSessionId(sessionId) + + const container = document.createElement('div') + container.className = 'h-full w-full overflow-auto' const token = getToken() const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' @@ -36,28 +57,44 @@ export default function RemoteDesktop() { // so the tunnel URL itself must not already contain one. const tunnel = new Guacamole.WebSocketTunnel(`${proto}://${window.location.host}/api/guacamole`) const client = new Guacamole.Client(tunnel) - clientRef.current = client + handlesRef.current.set(sessionId, { client, container }) client.onerror = (err: { message?: string }) => { - setStatus('error') - setErrorMessage(err?.message ?? 'Connection failed') + patchSession(sessionId, { status: 'error', errorMessage: err?.message ?? 'Connection failed' }) } client.onstatechange = (state: number) => { - if (state === 3) setStatus('connected') + if (state === 3) patchSession(sessionId, { status: 'connected' }) } - const display = client.getDisplay().getElement() - displayRef.current?.appendChild(display) - - client.connect(`token=${encodeURIComponent(token ?? '')}&integrationId=${id}`) + container.appendChild(client.getDisplay().getElement()) + client.connect(`token=${encodeURIComponent(token ?? '')}&integrationId=${host.id}`) } - function handleSelect(id: number) { - setHostId(id) - connect(id) + function closeSession(sessionId: string) { + const handle = handlesRef.current.get(sessionId) + handle?.client.disconnect() + handle?.container.remove() + handlesRef.current.delete(sessionId) + setSessions((prev) => { + const next = prev.filter((s) => s.sessionId !== sessionId) + if (activeSessionId === sessionId) { + setActiveSessionId(next.length > 0 ? next[next.length - 1].sessionId : null) + } + return next + }) } - const host = hosts.find((h) => h.id === hostId) + // Show only the active session's display element; keep the rest mounted off-DOM so + // background sessions stay connected while their tab isn't focused. + useEffect(() => { + const host = displayHostRef.current + if (!host) return + host.innerHTML = '' + const active = activeSessionId ? handlesRef.current.get(activeSessionId) : null + if (active) host.appendChild(active.container) + }, [activeSessionId]) + + const activeSession = sessions.find((s) => s.sessionId === activeSessionId) return (
@@ -73,12 +110,9 @@ export default function RemoteDesktop() { {hosts.map((h) => ( @@ -86,17 +120,67 @@ export default function RemoteDesktop() {
-
-

- {host ? host.name : 'Select a remote desktop'} -

-

- {status === 'connecting' && 'Connecting…'} - {status === 'connected' && 'Connected'} - {status === 'error' && `Error: ${errorMessage}`} -

+
+ {sessions.length === 0 && ( +

+ Select a remote desktop to connect +

+ )} + {sessions.map((s) => ( +
setActiveSessionId(s.sessionId)} + className="flex shrink-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-sm" + style={{ + background: activeSessionId === s.sessionId ? 'rgba(200,164,52,0.15)' : 'transparent', + color: activeSessionId === s.sessionId ? '#C8A434' : '#E8E6E0', + }} + > + + {s.name} + +
+ ))}
-
+ + {activeSession && ( +
+

+ {activeSession.name} +

+
+

+ {activeSession.status === 'connecting' && 'Connecting…'} + {activeSession.status === 'connected' && 'Connected'} + {activeSession.status === 'error' && `Error: ${activeSession.errorMessage}`} +

+ +
+
+ )} + +
)