/* global __DEVDESK_VERSION__ */ import { createTerminal, loadFonts } from './term.js'; import { TtydConnection, fetchRepo, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js'; import { parseTitle, sessionFromSearch } from './protocol.js'; import { SessionsPanel, rememberSession } from './sessions.js'; import { renderContext } from './context.js'; import { renderRepo } from './repo.js'; import { bindAskClaude } from './widgets.js'; import * as notify from './notify.js'; const $ = (id) => document.getElementById(id); const store = { get(key, fallback) { try { const value = localStorage.getItem(`devdesk.${key}`); return value === null ? fallback : value; } catch { return fallback; } }, set(key, value) { try { localStorage.setItem(`devdesk.${key}`, String(value)); } catch { // Private mode: preferences just won't persist. } }, }; const ui = { body: document.body, wsStatus: $('ws-status'), wsLabel: $('ws-label'), sso: $('sso-badge'), sessionChip: $('session-chip'), paneTitle: $('pane-title'), overlay: $('overlay'), overlayTitle: $('overlay-title'), overlayText: $('overlay-text'), overlayActions: $('overlay-actions'), notifyButton: $('notify-toggle'), sidebarButton: $('sidebar-toggle'), }; const state = { session: sessionFromSearch(window.location.search), // null = picker conn: null, attempts: 0, retryTimer: null, openedAt: 0, endingCurrent: false, lastActivity: Date.now(), }; let term; let fit; let sessionsPanel; // ---------------------------------------------------------------- status UI function setWsState(stateName, label) { ui.wsStatus.dataset.state = stateName; ui.wsLabel.textContent = label; } function setSso(stateName) { ui.sso.dataset.state = stateName; ui.sso.textContent = stateName === 'expired' ? 'SSO expired' : 'SSO'; ui.sso.title = stateName === 'expired' ? 'Authelia session expired. Click to sign in again.' : 'Signed in through Authelia'; } function showOverlay({ title, text, actions = [], tone = 'info' }) { ui.overlay.hidden = false; ui.overlay.dataset.tone = tone; ui.overlayTitle.textContent = title; ui.overlayText.textContent = text; ui.overlayActions.replaceChildren(...actions.map(({ label, run, primary }) => { const button = document.createElement('button'); button.type = 'button'; button.className = `lt-btn lt-btn-sm${primary ? ' lt-btn-primary' : ''}`; button.textContent = label; button.addEventListener('click', run); return button; })); } function hideOverlay() { ui.overlay.hidden = true; } function showContext() { const data = sessionsPanel?.data; const current = data?.sessions?.find((s) => s.name === state.session); renderContext($('widget-context'), state.session, current?.context ?? null); } // Repo / CI panel for the current session; notifies when HEAD's CI finishes. let lastCi = { key: '', state: '' }; let repoBusy = false; async function refreshRepo() { const session = state.session; if (!session) { renderRepo($('widget-repo'), null); return; } if (repoBusy) return; repoBusy = true; try { const repo = await fetchRepo(session); if (session !== state.session) return; renderRepo($('widget-repo'), repo); const key = `${session}@${repo.sha}`; const ci = repo.ci?.total ? repo.ci.state : ''; if (key === lastCi.key && lastCi.state === 'pending' && ['success', 'failure', 'error'].includes(ci)) { notify.ring(session, `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`); } lastCi = { key, state: ci }; } catch { // Keep the last rendering; the next refresh retries. } finally { repoBusy = false; } } let refreshTimer = 0; function refreshSoon(delay = 1500) { clearTimeout(refreshTimer); refreshTimer = setTimeout(() => sessionsPanel?.refresh(), delay); } function updateUrl(session) { const url = new URL(window.location.href); if (session) url.searchParams.set('arg', session); else url.searchParams.delete('arg'); if (url.href !== window.location.href) window.history.replaceState(null, '', url); } // ---------------------------------------------------------------- connection function connect(session) { clearTimeout(state.retryTimer); state.conn?.dispose(); state.session = session; updateUrl(session); sessionsPanel?.setCurrent(session); showContext(); ui.sessionChip.textContent = session || 'picker'; hideOverlay(); setWsState('connecting', 'connecting'); state.openedAt = 0; term.reset(); const conn = new TtydConnection({ arg: session, size: () => ({ cols: term.cols, rows: term.rows }), onOpen: () => { state.openedAt = Date.now(); state.attempts = 0; setWsState('connected', 'live'); setSso('ok'); if (session) rememberSession(session); refreshRepo(); fit.fit(); term.focus(); }, onOutput: (bytes) => term.write(bytes, conn.flowWrite(bytes.length)), onClose: (code) => onClosed(code), }); state.conn = conn; conn.open().catch(onFailure); } const pickerAction = { label: 'Sessions…', run: () => connect(null) }; function onClosed(code) { setWsState('disconnected', 'offline'); if (state.endingCurrent) { // We just ended the session we were attached to: go back to the picker. state.endingCurrent = false; connect(null); return; } // ttyd reports a non-zero exit of `devdesk attach` with an invalid close code, // which browsers surface as 1006 — same as a dropped network. A close within a // few seconds of opening means the session could not start (the reason is // printed in the terminal), so don't retry forever. const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000; if (failedFast) { showOverlay({ title: 'Session did not start', text: `Could not open "${state.session || 'picker'}". The reason is shown in the terminal behind this message.`, actions: [{ label: 'Retry', run: () => connect(state.session) }, pickerAction], tone: 'warn', }); return; } // 1000: the attach process exited normally (detached, session ended, picker // closed). Anything else is the network or ttyd going away: reconnect. if (code === 1000) { const actions = state.session ? [{ label: `Reattach ${state.session}`, primary: true, run: () => connect(state.session) }, pickerAction] : [{ label: 'Open picker', primary: true, run: () => connect(null) }]; showOverlay({ title: 'Detached', text: 'The terminal closed. Any session you left keeps running on the server.', actions, }); return; } scheduleReconnect(); } function onFailure(error) { if (error instanceof AuthExpiredError) { setSso('expired'); setWsState('disconnected', 'signed out'); showOverlay({ title: 'Sign-in expired', text: 'Your Authelia session has expired. Claude keeps running on the server; sign in again to reattach.', actions: [{ label: 'Re-authenticate', primary: true, run: () => window.location.reload() }], tone: 'warn', }); return; } if (error instanceof UnauthorizedError) { setWsState('disconnected', 'denied'); showOverlay({ title: 'Not authorized', text: 'ttyd rejected the request. Open DevDesk through https://dev.lotusguild.org, where the proxy adds the credential.', actions: [{ label: 'Retry', run: () => connect(state.session) }], tone: 'danger', }); return; } scheduleReconnect(); } function scheduleReconnect() { clearTimeout(state.retryTimer); state.attempts += 1; const delay = Math.min(30000, 1000 * 1.6 ** (state.attempts - 1)) * (0.8 + Math.random() * 0.4); const seconds = Math.ceil(delay / 1000); setWsState('connecting', `retry ${seconds}s`); showOverlay({ title: 'Reconnecting', text: `Connection lost; Claude keeps running on the server. Retrying in ${seconds}s (attempt ${state.attempts}).`, actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session) }, pickerAction], }); state.retryTimer = setTimeout(() => connect(state.session), delay); } function retrySoon() { if (state.retryTimer && !state.conn?.isOpen) connect(state.session); } // ---------------------------------------------------------------- titles & bell let titleFrame = 0; let pendingTitle = ''; function applyTitle() { titleFrame = 0; const parsed = parseTitle(pendingTitle); if (!parsed) { ui.paneTitle.textContent = pendingTitle; return; } if (parsed.session === 'picker') { ui.sessionChip.textContent = 'picker'; ui.paneTitle.textContent = 'choose a session'; notify.setBaseTitle('DevDesk · picker'); return; } if (parsed.session && parsed.session !== state.session) { // Chosen in the picker or switched inside tmux: follow it. state.session = parsed.session; updateUrl(parsed.session); rememberSession(parsed.session); sessionsPanel.setCurrent(parsed.session); showContext(); } ui.sessionChip.textContent = parsed.session; ui.paneTitle.textContent = parsed.pane; notify.setBaseTitle(`${parsed.session} · DevDesk`); } // ---------------------------------------------------------------- chrome controls function setFontSize(size) { const clamped = Math.min(24, Math.max(10, size)); term.options.fontSize = clamped; store.set('fontSize', clamped); fit.fit(); } function renderNotifyButton() { const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' }; const current = notify.notificationsState(); ui.notifyButton.textContent = labels[current]; ui.notifyButton.dataset.state = current; } function setSidebar(open) { ui.body.classList.toggle('dd-sidebar-open', open); ui.sidebarButton.setAttribute('aria-expanded', String(open)); store.set('sidebar', open ? 'open' : 'closed'); if (open) sessionsPanel?.refresh(); } function wireChrome() { $('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1)); $('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1)); ui.notifyButton.addEventListener('click', async () => { await notify.requestNotifications(); renderNotifyButton(); }); renderNotifyButton(); $('fullscreen-toggle').addEventListener('click', async () => { if (document.fullscreenElement) { await document.exitFullscreen(); return; } await document.documentElement.requestFullscreen(); // Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen. navigator.keyboard?.lock?.().catch(() => {}); }); ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open'))); const wide = window.matchMedia('(min-width: 1100px)').matches; setSidebar(store.get('sidebar', wide ? 'open' : 'closed') === 'open'); ui.sso.addEventListener('click', () => { if (ui.sso.dataset.state === 'expired') window.location.reload(); }); // Ctrl+W closes the tab before the terminal sees it; ask first. window.addEventListener('beforeunload', (event) => { if (state.conn?.isOpen && state.session) { event.preventDefault(); event.returnValue = ''; } }); for (const type of ['keydown', 'pointerdown']) { document.addEventListener(type, () => { state.lastActivity = Date.now(); }, { capture: true, passive: true }); } // While you're active, touch the Authelia gate so its inactivity timer doesn't lapse. setInterval(async () => { if (Date.now() - state.lastActivity > 10 * 60 * 1000) return; try { await fetchToken(); setSso('ok'); } catch (error) { if (error instanceof AuthExpiredError) setSso('expired'); } }, 3 * 60 * 1000); window.addEventListener('online', retrySoon); document.addEventListener('visibilitychange', () => { if (!document.hidden) retrySoon(); }); setInterval(() => { if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh(); }, 15 * 1000); } // ---------------------------------------------------------------- boot async function boot() { $('version').textContent = `devdesk ${__DEVDESK_VERSION__}`; const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14)); await loadFonts(fontSize); ({ term, fit } = createTerminal($('terminal'), { fontSize, onShiftEnter: () => state.conn?.sendInput('\n'), })); term.onData((data) => state.conn?.sendInput(data)); term.onBinary((data) => state.conn?.sendBinary(data)); term.onResize(({ cols, rows }) => state.conn?.resize(cols, rows)); term.onTitleChange((title) => { pendingTitle = title; if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle); }); term.onBell(() => { notify.ring(state.session, ui.paneTitle.textContent); refreshSoon(); // Claude finished a turn: context numbers just changed }); let fitFrame = 0; new ResizeObserver(() => { if (!fitFrame) { fitFrame = requestAnimationFrame(() => { fitFrame = 0; fit.fit(); }); } }).observe($('terminal')); fit.fit(); sessionsPanel = new SessionsPanel({ list: $('sessions-list'), form: $('new-session-form'), nameInput: $('new-session-name'), select: $('project-select'), refreshButton: $('sessions-refresh'), pickerButton: $('open-picker'), status: $('sessions-status'), hooks: { onOpen: (name) => connect(name), onPicker: () => connect(null), onEnding: () => { state.endingCurrent = true; }, onEnded: (name) => { if (name !== state.session) state.endingCurrent = false; }, onEndAborted: () => { state.endingCurrent = false; }, onRenamed: (from, to) => { if (from !== state.session) return; state.session = to; updateUrl(to); rememberSession(to); ui.sessionChip.textContent = to; sessionsPanel.setCurrent(to); }, onData: () => { showContext(); refreshRepo(); }, }, }); bindAskClaude($('sidebar'), (text) => { state.conn?.sendInput(text); term.focus(); }); wireChrome(); connect(state.session); } boot();