/* global __DEVDESK_VERSION__ */ import { THEMES, createTerminal, loadFonts } from './term.js'; import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js'; import { TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError, } from './connection.js'; import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js'; import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js'; import { renderAlerts } from './alerts.js'; import { renderRepo } from './repo.js'; import { bindAskClaude } from './widgets.js'; import { heardWithin, onShared, share } from './shared.js'; import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js'; import { Inbox } from './inbox.js'; import { FilesPane } from './files.js'; import { el } from './dom.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'), reauth: $('reauth'), sessionChip: $('session-chip'), sessionName: $('session-name'), switcher: $('switcher'), attention: $('attention-chip'), paneTitle: $('pane-title'), overlay: $('overlay'), overlayTitle: $('overlay-title'), overlayText: $('overlay-text'), overlayActions: $('overlay-actions'), moreToggle: $('more-toggle'), moreMenu: $('more-menu'), notifyButton: $('notify-toggle'), sidebarButton: $('sidebar-toggle'), backdrop: $('backdrop'), }; const urlSession = sessionFromSearch(window.location.search); const state = { session: urlSession, // null = picker conn: null, attempts: 0, retryTimer: null, openedAt: 0, endingCurrent: false, lastActivity: Date.now(), }; const touch = window.matchMedia('(pointer: coarse)').matches; const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches; let term; let fit; let sessionsPanel; let palette; let inbox; let files; let lastRepo = null; let lastAlerts = null; let waitingOthers = []; let paneTitleRaw = ''; // ---------------------------------------------------------------- status UI function setWsState(stateName, label) { ui.wsStatus.dataset.state = stateName; ui.wsLabel.textContent = label; ui.wsStatus.title = label; } 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; } // Pane title (Claude's own title) only when it says more than the session name. function renderPaneTitle() { const bare = paneTitleRaw.replace(/^[^\p{L}\p{N}]+/u, '').trim(); ui.paneTitle.textContent = bare && bare !== state.session ? paneTitleRaw : ''; } function renderSessionChip() { const data = sessionsPanel?.data; const current = data?.sessions?.find((s) => s.name === state.session); const status = current ? sessionStatus(current) : null; ui.sessionName.textContent = state.session || 'picker'; ui.sessionChip.dataset.state = status?.key ?? ''; ui.sessionChip.title = [ state.session ? `Session ${state.session}${status ? ` · ${status.label}` : ''}` : 'Session picker', current?.status?.message, current?.context?.used_pct != null ? `context ${Math.floor(current.context.used_pct)}%` : '', 'Click to switch session', ].filter(Boolean).join('\n'); } // Other sessions waiting on you: header chip, a notification when one starts // waiting (devdesk#15), and the Panels badge. Only the tab that fetched the list // notifies, so several open tabs don't ring once each. let prevWaiting = new Set(); const prevStatus = new Map(); function renderAttention(fetched) { const data = sessionsPanel?.data; waitingOthers = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting'); for (const s of waitingOthers) { if (fetched && !prevWaiting.has(s.name)) notify.ring(s.name, s.status?.message || 'Claude needs your input'); } prevWaiting = new Set(waitingOthers.map((s) => s.name)); if (fetched) recordTransitions(data?.sessions ?? []); ui.attention.classList.toggle('is-active', waitingOthers.length > 0); ui.attention.textContent = waitingOthers.length === 1 ? `${waitingOthers[0].name} needs you` : `${waitingOthers.length} need you`; ui.attention.title = waitingOthers.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n'); ui.attention.dataset.target = waitingOthers[0]?.name || ''; renderPanelsBadge(); } // Inbox events for what happened while you were elsewhere: a session (other than // the one you're looking at) finished a turn or started waiting. Only the tab // that fetched the list records them; the inbox is shared via localStorage. function recordTransitions(sessions) { for (const s of sessions) { const key = sessionStatus(s).key; const prev = prevStatus.get(s.name); prevStatus.set(s.name, key); if (!prev || prev === key) continue; const away = s.name !== state.session || document.hidden; if (!away) continue; if (key === 'waiting') { inbox.record({ kind: 'waiting', session: s.name, text: `${s.name} needs you: ${s.status?.message || 'Claude is waiting for your input'}` }); } else if (prev === 'working' && key === 'idle') { inbox.record({ kind: 'finished', session: s.name, text: `${s.name} finished` }); } } } function renderPanelsBadge() { inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] }); } function onSessionData({ fetched = false } = {}) { renderSessionChip(); renderAttention(fetched); if (!ui.switcher.hidden) renderSwitcher(); } function showAlerts(data) { lastAlerts = data; renderAlerts($('widget-alerts'), data); renderPanelsBadge(); } let alertsBusy = false; async function refreshAlerts() { if (alertsBusy) return; alertsBusy = true; try { showAlerts(await fetchAlerts()); share('alerts', lastAlerts); } 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) { toast.warning('Open a session first: files are attached to the current session.'); 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) { uploadUi.hidden = true; toast.error(`${file.name || 'file'}: ${error.message}`); return; } } uploadUi.hidden = true; if (session !== state.session) return; // A pasted image path makes Claude attach the image itself. term.paste(`${paths.join(' ')} `); term.focus(); toast.success(paths.length === 1 ? `Attached ${paths[0].split('/').pop()}` : `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) { lastRepo = null; 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); lastRepo = 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)) { const text = `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`; notify.ring(session, text); toast[ci === 'success' ? 'success' : 'error'](text); inbox.record({ kind: ci === 'success' ? 'ci-pass' : 'ci-fail', session, text: `${session}: ${text}`, url: repo.ci?.url }); } 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); paneTitleRaw = ''; renderPaneTitle(); onSessionData(); files?.sessionChanged(); 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'); if (session) rememberSession(session); refreshSoon(800); // opening ?arg= just created this 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) { 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: 'Sign in again', 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) { paneTitleRaw = pendingTitle; renderPaneTitle(); return; } if (parsed.session === 'picker') { ui.sessionName.textContent = 'picker'; paneTitleRaw = 'choose a session'; renderPaneTitle(); 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); onSessionData(); refreshRepo(); files?.sessionChanged(); } paneTitleRaw = parsed.pane; renderPaneTitle(); notify.setBaseTitle(`${parsed.session} · DevDesk`); } // ---------------------------------------------------------------- menus const menus = []; function wireMenu(button, menu, onOpen) { menus.push({ button, menu }); button.addEventListener('click', (event) => { event.stopPropagation(); const open = menu.hidden; closeMenus(); if (open) { onOpen?.(); menu.hidden = false; button.setAttribute('aria-expanded', 'true'); } }); menu.addEventListener('click', (event) => event.stopPropagation()); } function closeMenus() { for (const { button, menu } of menus) { menu.hidden = true; button.setAttribute('aria-expanded', 'false'); } if (inbox?.isOpen) inbox.setOpen(false); } // Session switcher on the session chip (devdesk#16). function renderSwitcher() { const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) => (b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name)); const choose = (run) => () => { closeMenus(); run(); }; const items = sessions.map((s) => { const status = sessionStatus(s); const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : ''; return el('button', { type: 'button', role: 'menuitem', class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`, title: s.dir || '', onclick: choose(() => connect(s.name)), }, el('span', { class: `lt-dot ${status.dot}` }), el('span', { class: 'dd-switch-name' }, s.name), el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`)); }); if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…')); ui.switcher.replaceChildren( ...items, el('div', { class: 'dd-menu-sep' }), el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => sessionsPanel.newDialog()), }, '+ New session…'), el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'), ); } // ---------------------------------------------------------------- chrome controls function setFontSize(size) { if (!Number.isFinite(size)) return; const clamped = Math.min(24, Math.max(10, size)); term.options.fontSize = clamped; store.set('fontSize', clamped); document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`); $('font-size').textContent = String(clamped); fit.fit(); } async function toggleNotifications() { const current = notify.notificationsState(); if (current === 'default') { const result = await notify.requestNotifications(); if (result === 'granted') toast.success('Desktop notifications on'); else if (result === 'denied') toast.warning('The browser blocked notifications for this site'); } else if (current === 'granted') { notify.setMuted(!notify.isMuted()); toast.info(notify.isMuted() ? 'Desktop notifications muted in this browser' : 'Desktop notifications unmuted'); } else { toast.warning(current === 'denied' ? 'Notifications are blocked in the browser\'s site settings' : 'This browser has no notifications'); } } // ---------------------------------------------------------------- palette & help const sendKeys = (keys) => state.conn?.sendInput(keys); async function copyLink() { const url = new URL(window.location.href); url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : ''; try { await navigator.clipboard.writeText(url.href); toast.success('Link copied'); } catch { toast.error('Could not copy: the browser refused clipboard access'); } } const openUrl = (url) => window.open(url, '_blank', 'noopener'); // Commands for the palette (devdesk#32), rebuilt on every open. function buildCommands() { const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) => (b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name)); const current = sessions.find((s) => s.name === state.session); const panelsOpen = ui.body.classList.contains('dd-sidebar-open'); const notifyState = notify.notificationsState(); const commands = []; const add = (command) => commands.push(command); for (const s of sessions) { if (s.name === state.session) continue; const status = sessionStatus(s); add({ id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir, icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name), }); } add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() }); if (state.session) { add({ id: 'rename', group: 'Sessions', icon: '✎', label: `Rename ${state.session}…`, run: () => sessionsPanel.rename(state.session) }); add({ id: 'end', group: 'Sessions', icon: '×', label: `End ${state.session}…`, keywords: 'close kill stop', run: () => sessionsPanel.end(current ?? { name: state.session, running: true }), }); add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink }); } add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) }); if (state.session && state.conn?.isOpen) { add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') }); add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') }); add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') }); add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') }); } if (state.session) { add({ id: 'files', group: 'Files', icon: '🗀', label: files.isOpen ? 'Close files' : 'Browse files', kbd: 'Ctrl+⇧E', keywords: 'explorer folder read open view', run: () => files.toggle('files') }); add({ id: 'changes', group: 'Files', icon: '±', label: 'Review changes (git diff)', keywords: 'git status diff claude edits', run: () => files.open('changes') }); } add({ id: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color', label: getTheme() === 'light' ? 'Switch to dark mode' : 'Switch to light mode', run: toggleTheme, }); add({ id: 'panels', group: 'View', icon: '▤', label: panelsOpen ? 'Hide panels' : 'Show panels', keywords: 'sidebar', run: () => setSidebar(!panelsOpen) }); add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) }); add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) }); add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) }); add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen }); if (notifyState !== 'unsupported') { add({ id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell', label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState], run: () => toggleNotifications().then(renderNotifyButton), }); } if (lastRepo?.url) { add({ id: 'open-repo', group: 'Links', icon: '↗', label: `Open ${lastRepo.repo} in Gitea`, run: () => openUrl(lastRepo.url) }); add({ id: 'open-prs', group: 'Links', icon: '↗', label: 'Open pull requests', run: () => openUrl(`${lastRepo.url}/pulls`) }); if (lastRepo.ci?.url) add({ id: 'open-ci', group: 'Links', icon: '↗', label: `Open CI run (${lastRepo.ci.state})`, run: () => openUrl(lastRepo.ci.url) }); } if (lastAlerts?.alerts?.length) { add({ id: 'alerts', group: 'Links', icon: '!', label: `Show ${lastAlerts.alerts.length} ops alert(s)`, keywords: 'grafana', run: () => { setSidebar(true); $('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' }); }, }); } add({ id: 'help', group: 'Help', icon: '?', label: 'Keyboard shortcuts', keywords: 'keys help', run: showHelp }); // After a command, typing goes back to the terminal unless it opened a dialog. return commands.map((c) => ({ ...c, run: () => { c.run(); if (!dialogOpen()) term.focus(); }, })); } const KEY_HELP = [ ['Claude', [ ['⇧ Enter', 'New line in the prompt'], ['Esc', 'Interrupt Claude'], ['⇧ Tab', 'Cycle mode (auto-accept, plan…)'], ['↑ / ↓', 'Previous / next prompt'], ['Ctrl+C', 'Cancel; twice to quit (the menu offers resume)'], ]], ['Windows & sessions (tmux)', [ ['Ctrl-b 0 / 1', 'Claude window / shell window'], ['Ctrl-b s', 'Switch session (the URL follows)'], ['Ctrl-b [', 'Scroll back; q to leave'], ['Ctrl-b d', 'Detach (the session keeps running)'], ]], ['DevDesk', [ ['Ctrl+⇧ P', 'Command palette'], ['Ctrl+⇧ E', 'Files: browse, preview, diff (Esc closes)'], ['Ctrl+⇧ C / V', 'Copy selection / paste'], ['⇧ drag', 'Select text in the browser'], ['Drop or paste a file', 'Upload it and attach it for Claude'], ['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'], ]], ]; function showHelp() { const body = el('div', { class: 'dd-keys' }, ...KEY_HELP.map(([title, rows]) => el('section', {}, el('h3', { class: 'dd-keys-title' }, title), el('dl', { class: 'dd-keys-list' }, ...rows.flatMap(([keys, what]) => [el('dt', {}, el('kbd', {}, keys)), el('dd', {}, what)]))))); if (touch) body.append(el('p', { class: 'dd-dialog-hint' }, 'On a phone, the key bar under the terminal sends Esc, Tab, ⇧Tab, Ctrl+C, arrows, Enter and the window switches.')); openDialog({ title: 'Keyboard shortcuts', body, actions: [{ label: 'Close', primary: true }] }).then(() => term.focus()); } // Drag the files pane's left edge (or use ←/→ on it). The terminal keeps at // least ~60 columns; the width is remembered per browser. function wireResizer() { const main = document.querySelector('.dd-main'); const handle = $('files-resizer'); const apply = (px) => { const max = main.clientWidth - 520; const width = Math.round(Math.max(340, Math.min(max, px))); main.style.setProperty('--dd-files-w', `${width}px`); return width; }; const saved = Number(store.get('filesWidth', 0)); if (saved) apply(saved); handle.addEventListener('pointerdown', (event) => { event.preventDefault(); handle.setPointerCapture(event.pointerId); ui.body.classList.add('dd-resizing'); const move = (e) => apply(main.getBoundingClientRect().right - e.clientX); const up = () => { ui.body.classList.remove('dd-resizing'); handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up); store.set('filesWidth', $('files').getBoundingClientRect().width); }; handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', up); }); handle.addEventListener('keydown', (event) => { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); const width = apply($('files').getBoundingClientRect().width + (event.key === 'ArrowLeft' ? 40 : -40)); store.set('filesWidth', width); }); } async function toggleFullscreen() { 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(() => {}); } function renderNotifyButton() { const current = notify.notificationsState(); const labels = { unsupported: 'Desktop notifications: unsupported', denied: 'Desktop notifications: blocked by the browser', default: 'Enable desktop notifications', granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)', }; ui.notifyButton.textContent = labels[current]; ui.notifyButton.disabled = current === 'unsupported' || current === 'denied'; } function setSidebar(open) { if (open && files?.isOpen) files.close(); ui.body.classList.toggle('dd-sidebar-open', open); ui.sidebarButton.setAttribute('aria-expanded', String(open)); ui.backdrop.hidden = !(open && isNarrow()); if (!isNarrow()) store.set('sidebar', open ? 'open' : 'closed'); if (open) { sessionsPanel?.refresh(); refreshAlerts(); refreshRepo(); } renderPanelsBadge(); } function closeDrawerIfNarrow() { if (isNarrow() && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false); } // Phone key bar: keys an on-screen keyboard can't send (devdesk#14). const KEYS = { esc: '\x1b', tab: '\t', stab: '\x1b[Z', ctrlc: '\x03', up: '\x1b[A', down: '\x1b[B', enter: '\r', claude: '\x020', shell: '\x021', // tmux prefix (C-b) + window number }; function wireKeybar() { if (!touch) return; ui.body.classList.add('dd-touch'); const bar = $('keybar'); bar.hidden = false; // Don't steal focus from the terminal (that would close the on-screen keyboard). bar.addEventListener('pointerdown', (event) => event.preventDefault()); bar.addEventListener('click', (event) => { const key = event.target.closest('[data-keys]')?.dataset.keys; if (key && KEYS[key]) state.conn?.sendInput(KEYS[key]); }); // Keep the terminal above the on-screen keyboard. const vv = window.visualViewport; if (vv) { const update = () => { const hidden = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); document.documentElement.style.setProperty('--dd-kb', `${hidden}px`); }; vv.addEventListener('resize', update); vv.addEventListener('scroll', update); } } 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 toggleNotifications(); renderNotifyButton(); }); $('fullscreen-toggle').addEventListener('click', () => { closeMenus(); toggleFullscreen(); }); $('help-open').addEventListener('click', () => { closeMenus(); showHelp(); }); $('footer-help').addEventListener('click', showHelp); $('palette-menu').addEventListener('click', () => { closeMenus(); palette.open(); }); files = new FilesPane($('files'), { session: () => state.session, paste: (text) => { term.paste(text); }, focusTerminal: () => term.focus(), onToggle: (open) => { ui.body.classList.toggle('dd-files-open', open); $('files-toggle').setAttribute('aria-expanded', String(open)); if (open && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false); }, }); $('files-toggle').addEventListener('click', () => files.toggle()); wireResizer(); palette = new CommandPalette(buildCommands); $('cmd-toggle').addEventListener('click', () => palette.open()); // Ctrl+Shift+P (⌘⇧P): Ctrl+K belongs to the terminal (kill-line in Claude and bash). document.addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyE') { event.preventDefault(); event.stopPropagation(); if (state.session) files.toggle(); return; } if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') { event.preventDefault(); event.stopPropagation(); if (palette.isOpen) palette.close(); else if (!dialogOpen()) palette.open(); } }, true); wireMenu(ui.moreToggle, ui.moreMenu, () => { renderNotifyButton(); $('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode'; }); $('theme-menu').addEventListener('click', () => { closeMenus(); toggleTheme(); }); inbox = new Inbox({ button: $('inbox-toggle'), badge: $('inbox-badge'), panel: $('inbox-panel'), hooks: { openSession: (name) => connect(name), showAlerts: () => { setSidebar(true); $('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' }); }, }, }); $('inbox-toggle').addEventListener('click', (event) => { event.stopPropagation(); const open = !inbox.isOpen; closeMenus(); inbox.setOpen(open); }); $('inbox-panel').addEventListener('click', (event) => event.stopPropagation()); wireMenu(ui.sessionChip, ui.switcher, () => { renderSwitcher(); sessionsPanel.refresh(); }); document.addEventListener('click', closeMenus); ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open'))); ui.backdrop.addEventListener('click', () => setSidebar(false)); setSidebar(!isNarrow() && store.get('sidebar', 'open') === 'open'); window.matchMedia('(min-width: 1100px)').addEventListener('change', () => { ui.backdrop.hidden = !(ui.body.classList.contains('dd-sidebar-open') && isNarrow()); }); // Esc closes menus and the narrow-screen drawer before the terminal sees it. document.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || dialogOpen()) return; // Files pane: Esc goes back / closes, unless the terminal or CodeMirror's // own search panel has the focus. if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) { event.preventDefault(); event.stopPropagation(); files.escape(); return; } const menuOpen = menus.some(({ menu }) => !menu.hidden) || inbox.isOpen; const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open'); if (!menuOpen && !drawerOpen) return; event.preventDefault(); event.stopPropagation(); closeMenus(); if (drawerOpen) setSidebar(false); }, true); // "Sign in again" appears only when a request finds the SSO session expired (devdesk#13). window.addEventListener('devdesk:auth', (event) => { ui.reauth.hidden = event.detail !== 'expired'; }); ui.reauth.addEventListener('click', () => 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(() => { if (Date.now() - state.lastActivity > 10 * 60 * 1000) return; fetchToken().catch(() => {}); }, 3 * 60 * 1000); window.addEventListener('online', retrySoon); document.addEventListener('visibilitychange', () => { if (!document.hidden) retrySoon(); }); // Session states feed the header chip and the notifications for other sessions, // so poll even in a background tab (browsers throttle that to about once a // minute) — unless another tab just shared a fresh list (devdesk#8, #15). onShared('sessions', (data) => sessionsPanel.accept(data)); setInterval(() => { if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh(); }, 15 * 1000); // Alerts feed the Panels badge too. onShared('alerts', showAlerts); setInterval(() => { if (!heardWithin('alerts', 62 * 1000)) refreshAlerts(); }, 60 * 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); }); } // A bare URL (no ?arg=) reattaches the session you used last, or the most // recently active one; the picker is only the fallback (devdesk#16). function defaultSession(data) { const sessions = data?.sessions ?? []; const known = new Set(sessions.map((s) => s.name)); const recent = recentNames().find((name) => known.has(name)); if (recent) return recent; const running = sessions.filter((s) => s.running).sort((a, b) => (b.activity || 0) - (a.activity || 0)); return running[0]?.name ?? null; } // ---------------------------------------------------------------- boot async function boot() { initTheme(); $('version').textContent = `devdesk ${__DEVDESK_VERSION__}`; const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14)); $('font-size').textContent = String(fontSize); await loadFonts(fontSize); ({ term, fit } = createTerminal($('terminal'), { fontSize, theme: getTheme(), onShiftEnter: () => state.conn?.sendInput('\n'), })); onThemeChange((theme) => { term.options.theme = THEMES[theme]; files?.themeChanged(); }); $('theme-toggle').addEventListener('click', toggleTheme); 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 || 'Claude needs your attention'); refreshSoon(); // Claude finished a turn: states and context just changed setTimeout(refreshRepo, 2000); // …and maybe committed or pushed setTimeout(() => files?.claudeTurnEnded(), 1200); // …or edited files }); 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'), newToggle: $('new-session-toggle'), status: $('sessions-status'), hooks: { onOpen: (name) => { closeDrawerIfNarrow(); connect(name); }, 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); sessionsPanel.setCurrent(to); renderSessionChip(); }, onData: (_data, meta) => onSessionData(meta), }, }); 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)); closeDrawerIfNarrow(); term.focus(); }); wireChrome(); wireKeybar(); wireUploads(); refreshAlerts(); await sessionsPanel.refresh(); connect(urlSession ?? defaultSession(sessionsPanel.data)); } boot();