Support multiple remote desktop sessions as tabs with disconnect controls

This commit is contained in:
Claude 2026-06-22 18:44:42 +00:00
parent a47583a8e4
commit 05e78f0fa5
No known key found for this signature in database

View file

@ -4,13 +4,27 @@ import { api, getToken, type Integration } from '../lib/api'
const TEXT_SECONDARY = '#7A7D85' 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() { export default function RemoteDesktop() {
const [hosts, setHosts] = useState<Integration[]>([]) const [hosts, setHosts] = useState<Integration[]>([])
const [hostId, setHostId] = useState<number | null>(null) const [sessions, setSessions] = useState<Session[]>([])
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle') const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState('') const displayHostRef = useRef<HTMLDivElement>(null)
const displayRef = useRef<HTMLDivElement>(null) const handlesRef = useRef<Map<string, SessionHandle>>(new Map())
const clientRef = useRef<any>(null)
useEffect(() => { useEffect(() => {
api.listIntegrations().then(({ integrations }) => { api.listIntegrations().then(({ integrations }) => {
@ -20,15 +34,22 @@ export default function RemoteDesktop() {
useEffect(() => { useEffect(() => {
return () => { return () => {
clientRef.current?.disconnect() handlesRef.current.forEach(({ client }) => client.disconnect())
} }
}, []) }, [])
function connect(id: number) { function patchSession(sessionId: string, patch: Partial<Session>) {
clientRef.current?.disconnect() setSessions((prev) => prev.map((s) => (s.sessionId === sessionId ? { ...s, ...patch } : s)))
if (displayRef.current) displayRef.current.innerHTML = '' }
setStatus('connecting')
setErrorMessage('') 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 token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' 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. // so the tunnel URL itself must not already contain one.
const tunnel = new Guacamole.WebSocketTunnel(`${proto}://${window.location.host}/api/guacamole`) const tunnel = new Guacamole.WebSocketTunnel(`${proto}://${window.location.host}/api/guacamole`)
const client = new Guacamole.Client(tunnel) const client = new Guacamole.Client(tunnel)
clientRef.current = client handlesRef.current.set(sessionId, { client, container })
client.onerror = (err: { message?: string }) => { client.onerror = (err: { message?: string }) => {
setStatus('error') patchSession(sessionId, { status: 'error', errorMessage: err?.message ?? 'Connection failed' })
setErrorMessage(err?.message ?? 'Connection failed')
} }
client.onstatechange = (state: number) => { client.onstatechange = (state: number) => {
if (state === 3) setStatus('connected') if (state === 3) patchSession(sessionId, { status: 'connected' })
} }
const display = client.getDisplay().getElement() container.appendChild(client.getDisplay().getElement())
displayRef.current?.appendChild(display) client.connect(`token=${encodeURIComponent(token ?? '')}&integrationId=${host.id}`)
client.connect(`token=${encodeURIComponent(token ?? '')}&integrationId=${id}`)
} }
function handleSelect(id: number) { function closeSession(sessionId: string) {
setHostId(id) const handle = handlesRef.current.get(sessionId)
connect(id) 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 ( return (
<div className="flex h-full gap-4"> <div className="flex h-full gap-4">
@ -73,12 +110,9 @@ export default function RemoteDesktop() {
{hosts.map((h) => ( {hosts.map((h) => (
<button <button
key={h.id} key={h.id}
onClick={() => handleSelect(h.id)} onClick={() => openSession(h)}
className="mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm transition" className="mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm transition hover:bg-white/10"
style={{ style={{ color: '#E8E6E0' }}
background: hostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
color: hostId === h.id ? '#C8A434' : '#E8E6E0',
}}
> >
{h.name} {h.name}
</button> </button>
@ -86,17 +120,67 @@ export default function RemoteDesktop() {
</div> </div>
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-white/10 bg-black"> <div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-white/10 bg-black">
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2"> <div className="flex items-center gap-1 overflow-x-auto border-b border-white/10 px-2 py-1.5">
<p className="text-sm" style={{ color: TEXT_SECONDARY }}> {sessions.length === 0 && (
{host ? host.name : 'Select a remote desktop'} <p className="px-1 text-sm" style={{ color: TEXT_SECONDARY }}>
</p> Select a remote desktop to connect
<p className="text-xs" style={{ color: TEXT_SECONDARY }}> </p>
{status === 'connecting' && 'Connecting…'} )}
{status === 'connected' && 'Connected'} {sessions.map((s) => (
{status === 'error' && `Error: ${errorMessage}`} <div
</p> key={s.sessionId}
onClick={() => 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',
}}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
background: s.status === 'connected' ? '#4ADE80' : s.status === 'error' ? '#F87171' : '#FBBF24',
}}
/>
{s.name}
<button
onClick={(e) => {
e.stopPropagation()
closeSession(s.sessionId)
}}
className="ml-1 rounded px-1 text-xs hover:bg-white/10"
style={{ color: TEXT_SECONDARY }}
title="Disconnect"
>
</button>
</div>
))}
</div> </div>
<div ref={displayRef} className="flex-1 overflow-auto" />
{activeSession && (
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
{activeSession.name}
</p>
<div className="flex items-center gap-3">
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
{activeSession.status === 'connecting' && 'Connecting…'}
{activeSession.status === 'connected' && 'Connected'}
{activeSession.status === 'error' && `Error: ${activeSession.errorMessage}`}
</p>
<button
onClick={() => closeSession(activeSession.sessionId)}
className="rounded-md border border-white/10 px-2 py-1 text-xs transition hover:bg-white/10"
style={{ color: '#E8E6E0' }}
>
Disconnect
</button>
</div>
</div>
)}
<div ref={displayHostRef} className="flex-1 overflow-auto" />
</div> </div>
</div> </div>
) )