/* global __DEVDESK_VERSION__ */ import { createTerminal, loadFonts } from './term.js'; import { TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError, } from './connection.js'; import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js'; import { SessionsPanel, rememberSession, sessionStatus } from './sessions.js'; import { renderAlerts } from './alerts.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'), stateChip: $('state-chip'), attention: $('attention-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); showSessionState(data, current); } // Header: what Claude is doing here, and which other sessions are waiting on you. function showSessionState(data, current) { const status = current ? sessionStatus(current) : null; const key = status && ['working', 'waiting', 'idle'].includes(status.key) ? status.key : ''; ui.stateChip.dataset.state = key; ui.stateChip.textContent = key ? status.label : ''; ui.stateChip.title = current?.status?.message || ''; const waiting = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting'); ui.attention.classList.toggle('is-active', waiting.length > 0); ui.attention.textContent = waiting.length === 1 ? `${waiting[0].name} needs you` : `${waiting.length} need you`; ui.attention.title = waiting.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n'); ui.attention.dataset.target = waiting[0]?.name || ''; } let alertsBusy = false; async function refreshAlerts() { if (alertsBusy) return; alertsBusy = true; try { renderAlerts($('widget-alerts'), await fetchAlerts()); } catch { // Keep the last rendering; the next refresh retries. } finally { alertsBusy = false; } } // ---------------------------------------------------------------- file drop / paste const uploadUi = $('upload-status'); let uploadHideTimer = 0; function uploadMessage(text, isError = false, sticky = false) { clearTimeout(uploadHideTimer); uploadUi.hidden = false; uploadUi.textContent = text; uploadUi.classList.toggle('is-error', isError); if (!sticky) uploadHideTimer = setTimeout(() => { uploadUi.hidden = true; }, isError ? 8000 : 4000); } async function attachFiles(files) { if (!files.length) return; if (!state.session || !state.conn?.isOpen) { uploadMessage('Open a session first: files are attached to the current session.', true); return; } const session = state.session; const paths = []; for (const file of files) { try { const path = await uploadFile(session, file, (p) => uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true)); paths.push(path); } catch (error) { uploadMessage(`${file.name || 'file'}: ${error.message}`, true); return; } } if (session !== state.session) return; // A pasted image path makes Claude attach the image itself. term.paste(`${paths.join(' ')} `); term.focus(); uploadMessage(paths.length === 1 ? `Attached ${paths[0]}` : `Attached ${paths.length} files`); } function wireUploads() { const frame = document.querySelector('.dd-terminal-frame'); const overlay = $('drop-overlay'); let depth = 0; const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files'); frame.addEventListener('dragenter', (event) => { if (!hasFiles(event)) return; event.preventDefault(); depth += 1; $('drop-session').textContent = state.session || 'a session'; overlay.hidden = false; }); frame.addEventListener('dragover', (event) => { if (hasFiles(event)) event.preventDefault(); }); frame.addEventListener('dragleave', () => { depth = Math.max(0, depth - 1); if (!depth) overlay.hidden = true; }); frame.addEventListener('drop', (event) => { if (!hasFiles(event)) return; event.preventDefault(); depth = 0; overlay.hidden = true; attachFiles([...event.dataTransfer.files]); }); // Pasting a screenshot / copied file: upload it instead of pasting text. document.addEventListener('paste', (event) => { const files = [...(event.clipboardData?.files ?? [])]; if (!files.length) return; event.preventDefault(); event.stopPropagation(); attachFiles(files); }, true); } // 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 // reconnect: automatic re-attach after a drop. It must not re-create a session // that was ended or renamed meanwhile, so it asks for --existing (devdesk#4). function connect(session, { reconnect = false } = {}) { 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 && reconnect ? ['--existing', session] : 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, { reconnect: true }) }, pickerAction], }); state.retryTimer = setTimeout(() => connect(state.session, { reconnect: true }), delay); } function retrySoon() { if (state.retryTimer && !state.conn?.isOpen) connect(state.session, { reconnect: true }); } // ---------------------------------------------------------------- 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(); }); // Session states feed the header chips too, so refresh even with the sidebar closed. setInterval(() => { if (!document.hidden) sessionsPanel.refresh(); }, 15 * 1000); setInterval(() => { if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshAlerts(); }, 30 * 1000); // Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8). setInterval(() => { if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo(); }, 60 * 1000); ui.attention.addEventListener('click', () => { if (ui.attention.dataset.target) connect(ui.attention.dataset.target); }); } // ---------------------------------------------------------------- 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 setTimeout(refreshRepo, 2000); // …and maybe committed or pushed }); 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(), }, }); bindAskClaude($('sidebar'), (text) => { // Alert text is untrusted: no control characters (a newline would run it in a // shell window, CR would submit), and bracketed paste around it (devdesk#2). term.paste(pasteSafe(text)); term.focus(); }); wireChrome(); wireUploads(); sessionsPanel.refresh(); refreshAlerts(); connect(state.session); } boot();