2026-10-06 23:11:07 -04:00
|
|
|
/* global __DEVDESK_VERSION__ */
|
|
|
|
|
import { createTerminal, loadFonts } from './term.js';
|
2026-10-07 19:26:06 -04:00
|
|
|
import {
|
|
|
|
|
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
|
|
|
|
} from './connection.js';
|
2026-10-07 20:15:47 -04:00
|
|
|
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
2026-10-07 23:34:59 -04:00
|
|
|
import { SessionsPanel, el, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
2026-10-07 19:26:06 -04:00
|
|
|
import { renderAlerts } from './alerts.js';
|
2026-10-07 13:09:32 -04:00
|
|
|
import { renderRepo } from './repo.js';
|
2026-10-06 23:11:07 -04:00
|
|
|
import { bindAskClaude } from './widgets.js';
|
2026-10-07 23:34:59 -04:00
|
|
|
import { heardWithin, onShared, share } from './shared.js';
|
2026-10-06 23:11:07 -04:00
|
|
|
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'),
|
2026-10-07 23:34:59 -04:00
|
|
|
reauth: $('reauth'),
|
2026-10-06 23:11:07 -04:00
|
|
|
sessionChip: $('session-chip'),
|
2026-10-07 23:34:59 -04:00
|
|
|
sessionName: $('session-name'),
|
|
|
|
|
switcher: $('switcher'),
|
2026-10-07 19:26:06 -04:00
|
|
|
attention: $('attention-chip'),
|
2026-10-06 23:11:07 -04:00
|
|
|
paneTitle: $('pane-title'),
|
|
|
|
|
overlay: $('overlay'),
|
|
|
|
|
overlayTitle: $('overlay-title'),
|
|
|
|
|
overlayText: $('overlay-text'),
|
|
|
|
|
overlayActions: $('overlay-actions'),
|
2026-10-07 23:34:59 -04:00
|
|
|
moreToggle: $('more-toggle'),
|
|
|
|
|
moreMenu: $('more-menu'),
|
2026-10-06 23:11:07 -04:00
|
|
|
notifyButton: $('notify-toggle'),
|
|
|
|
|
sidebarButton: $('sidebar-toggle'),
|
2026-10-07 23:34:59 -04:00
|
|
|
panelsBadge: $('panels-badge'),
|
|
|
|
|
backdrop: $('backdrop'),
|
2026-10-06 23:11:07 -04:00
|
|
|
};
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
const urlSession = sessionFromSearch(window.location.search);
|
2026-10-06 23:11:07 -04:00
|
|
|
const state = {
|
2026-10-07 23:34:59 -04:00
|
|
|
session: urlSession, // null = picker
|
2026-10-06 23:11:07 -04:00
|
|
|
conn: null,
|
|
|
|
|
attempts: 0,
|
|
|
|
|
retryTimer: null,
|
|
|
|
|
openedAt: 0,
|
2026-10-07 03:03:09 -04:00
|
|
|
endingCurrent: false,
|
2026-10-06 23:11:07 -04:00
|
|
|
lastActivity: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
const touch = window.matchMedia('(pointer: coarse)').matches;
|
|
|
|
|
const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches;
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
let term;
|
|
|
|
|
let fit;
|
|
|
|
|
let sessionsPanel;
|
2026-10-07 23:34:59 -04:00
|
|
|
let lastAlerts = null;
|
|
|
|
|
let waitingOthers = [];
|
|
|
|
|
let paneTitleRaw = '';
|
2026-10-06 23:11:07 -04:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- status UI
|
|
|
|
|
|
|
|
|
|
function setWsState(stateName, label) {
|
|
|
|
|
ui.wsStatus.dataset.state = stateName;
|
|
|
|
|
ui.wsLabel.textContent = label;
|
2026-10-07 23:34:59 -04:00
|
|
|
ui.wsStatus.title = label;
|
2026-10-06 23:11:07 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
// 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() {
|
2026-10-07 03:03:09 -04:00
|
|
|
const data = sessionsPanel?.data;
|
|
|
|
|
const current = data?.sessions?.find((s) => s.name === state.session);
|
2026-10-07 23:34:59 -04:00
|
|
|
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');
|
2026-10-07 19:26:06 -04:00
|
|
|
}
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
// 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();
|
|
|
|
|
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));
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderPanelsBadge() {
|
|
|
|
|
const alerts = lastAlerts?.alerts ?? [];
|
|
|
|
|
const count = waitingOthers.length + alerts.length;
|
|
|
|
|
const urgent = waitingOthers.length > 0 || alerts.some((a) => a.severity === 'critical');
|
|
|
|
|
ui.panelsBadge.hidden = count === 0 || ui.body.classList.contains('dd-sidebar-open');
|
|
|
|
|
ui.panelsBadge.textContent = String(count);
|
|
|
|
|
ui.panelsBadge.classList.toggle('is-urgent', urgent);
|
|
|
|
|
ui.panelsBadge.title = [
|
|
|
|
|
waitingOthers.length ? `${waitingOthers.length} session(s) need you` : '',
|
|
|
|
|
alerts.length ? `${alerts.length} Grafana alert(s)` : '',
|
|
|
|
|
].filter(Boolean).join(' · ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onSessionData({ fetched = false } = {}) {
|
|
|
|
|
renderSessionChip();
|
|
|
|
|
renderAttention(fetched);
|
|
|
|
|
if (!ui.switcher.hidden) renderSwitcher();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showAlerts(data) {
|
|
|
|
|
lastAlerts = data;
|
|
|
|
|
renderAlerts($('widget-alerts'), data);
|
|
|
|
|
renderPanelsBadge();
|
2026-10-07 19:26:06 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let alertsBusy = false;
|
|
|
|
|
async function refreshAlerts() {
|
|
|
|
|
if (alertsBusy) return;
|
|
|
|
|
alertsBusy = true;
|
|
|
|
|
try {
|
2026-10-07 23:34:59 -04:00
|
|
|
showAlerts(await fetchAlerts());
|
|
|
|
|
share('alerts', lastAlerts);
|
2026-10-07 19:26:06 -04:00
|
|
|
} 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);
|
2026-10-07 03:03:09 -04:00
|
|
|
}
|
|
|
|
|
|
2026-10-07 13:09:32 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
let refreshTimer = 0;
|
|
|
|
|
function refreshSoon(delay = 1500) {
|
|
|
|
|
clearTimeout(refreshTimer);
|
|
|
|
|
refreshTimer = setTimeout(() => sessionsPanel?.refresh(), delay);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
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
|
|
|
|
|
|
2026-10-07 20:15:47 -04:00
|
|
|
// 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 } = {}) {
|
2026-10-06 23:11:07 -04:00
|
|
|
clearTimeout(state.retryTimer);
|
|
|
|
|
state.conn?.dispose();
|
|
|
|
|
state.session = session;
|
|
|
|
|
updateUrl(session);
|
|
|
|
|
sessionsPanel?.setCurrent(session);
|
2026-10-07 23:34:59 -04:00
|
|
|
paneTitleRaw = '';
|
|
|
|
|
renderPaneTitle();
|
|
|
|
|
onSessionData();
|
2026-10-06 23:11:07 -04:00
|
|
|
hideOverlay();
|
|
|
|
|
setWsState('connecting', 'connecting');
|
|
|
|
|
state.openedAt = 0;
|
|
|
|
|
term.reset();
|
|
|
|
|
|
|
|
|
|
const conn = new TtydConnection({
|
2026-10-07 20:15:47 -04:00
|
|
|
arg: session && reconnect ? ['--existing', session] : session,
|
2026-10-06 23:11:07 -04:00
|
|
|
size: () => ({ cols: term.cols, rows: term.rows }),
|
|
|
|
|
onOpen: () => {
|
|
|
|
|
state.openedAt = Date.now();
|
|
|
|
|
state.attempts = 0;
|
|
|
|
|
setWsState('connected', 'live');
|
|
|
|
|
if (session) rememberSession(session);
|
2026-10-07 23:42:03 -04:00
|
|
|
refreshSoon(800); // opening ?arg=<new name> just created this session
|
2026-10-07 13:09:32 -04:00
|
|
|
refreshRepo();
|
2026-10-06 23:11:07 -04:00
|
|
|
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');
|
2026-10-07 03:03:09 -04:00
|
|
|
if (state.endingCurrent) {
|
|
|
|
|
// We just ended the session we were attached to: go back to the picker.
|
|
|
|
|
state.endingCurrent = false;
|
|
|
|
|
connect(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-06 23:11:07 -04:00
|
|
|
// 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.',
|
2026-10-07 23:34:59 -04:00
|
|
|
actions: [{ label: 'Sign in again', primary: true, run: () => window.location.reload() }],
|
2026-10-06 23:11:07 -04:00
|
|
|
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}).`,
|
2026-10-07 20:15:47 -04:00
|
|
|
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session, { reconnect: true }) }, pickerAction],
|
2026-10-06 23:11:07 -04:00
|
|
|
});
|
2026-10-07 20:15:47 -04:00
|
|
|
state.retryTimer = setTimeout(() => connect(state.session, { reconnect: true }), delay);
|
2026-10-06 23:11:07 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function retrySoon() {
|
2026-10-07 20:15:47 -04:00
|
|
|
if (state.retryTimer && !state.conn?.isOpen) connect(state.session, { reconnect: true });
|
2026-10-06 23:11:07 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- titles & bell
|
|
|
|
|
|
|
|
|
|
let titleFrame = 0;
|
|
|
|
|
let pendingTitle = '';
|
|
|
|
|
|
|
|
|
|
function applyTitle() {
|
|
|
|
|
titleFrame = 0;
|
|
|
|
|
const parsed = parseTitle(pendingTitle);
|
|
|
|
|
if (!parsed) {
|
2026-10-07 23:34:59 -04:00
|
|
|
paneTitleRaw = pendingTitle;
|
|
|
|
|
renderPaneTitle();
|
2026-10-06 23:11:07 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (parsed.session === 'picker') {
|
2026-10-07 23:34:59 -04:00
|
|
|
ui.sessionName.textContent = 'picker';
|
|
|
|
|
paneTitleRaw = 'choose a session';
|
|
|
|
|
renderPaneTitle();
|
2026-10-06 23:11:07 -04:00
|
|
|
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);
|
2026-10-07 23:34:59 -04:00
|
|
|
onSessionData();
|
|
|
|
|
refreshRepo();
|
2026-10-06 23:11:07 -04:00
|
|
|
}
|
2026-10-07 23:34:59 -04:00
|
|
|
paneTitleRaw = parsed.pane;
|
|
|
|
|
renderPaneTitle();
|
2026-10-06 23:11:07 -04:00
|
|
|
notify.setBaseTitle(`${parsed.session} · DevDesk`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
// ---------------------------------------------------------------- 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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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(() => {
|
|
|
|
|
setSidebar(true);
|
|
|
|
|
sessionsPanel.toggleForm(true);
|
|
|
|
|
}),
|
|
|
|
|
}, '+ New session…'),
|
|
|
|
|
el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
// ---------------------------------------------------------------- chrome controls
|
|
|
|
|
|
|
|
|
|
function setFontSize(size) {
|
|
|
|
|
const clamped = Math.min(24, Math.max(10, size));
|
|
|
|
|
term.options.fontSize = clamped;
|
|
|
|
|
store.set('fontSize', clamped);
|
2026-10-07 23:34:59 -04:00
|
|
|
$('font-size').textContent = String(clamped);
|
2026-10-06 23:11:07 -04:00
|
|
|
fit.fit();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderNotifyButton() {
|
|
|
|
|
const current = notify.notificationsState();
|
2026-10-07 23:34:59 -04:00
|
|
|
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)',
|
|
|
|
|
};
|
2026-10-06 23:11:07 -04:00
|
|
|
ui.notifyButton.textContent = labels[current];
|
2026-10-07 23:34:59 -04:00
|
|
|
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
|
2026-10-06 23:11:07 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setSidebar(open) {
|
|
|
|
|
ui.body.classList.toggle('dd-sidebar-open', open);
|
|
|
|
|
ui.sidebarButton.setAttribute('aria-expanded', String(open));
|
2026-10-07 23:34:59 -04:00
|
|
|
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);
|
|
|
|
|
}
|
2026-10-06 23:11:07 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 () => {
|
2026-10-07 23:34:59 -04:00
|
|
|
if (notify.notificationsState() === 'default') await notify.requestNotifications();
|
|
|
|
|
else if (notify.notificationsState() === 'granted') notify.setMuted(!notify.isMuted());
|
2026-10-06 23:11:07 -04:00
|
|
|
renderNotifyButton();
|
|
|
|
|
});
|
|
|
|
|
$('fullscreen-toggle').addEventListener('click', async () => {
|
2026-10-07 23:34:59 -04:00
|
|
|
closeMenus();
|
2026-10-06 23:11:07 -04:00
|
|
|
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(() => {});
|
|
|
|
|
});
|
2026-10-07 23:34:59 -04:00
|
|
|
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
|
|
|
|
|
wireMenu(ui.sessionChip, ui.switcher, () => {
|
|
|
|
|
renderSwitcher();
|
|
|
|
|
sessionsPanel.refresh();
|
|
|
|
|
});
|
|
|
|
|
document.addEventListener('click', closeMenus);
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
|
2026-10-07 23:34:59 -04:00
|
|
|
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());
|
|
|
|
|
});
|
2026-10-06 23:11:07 -04:00
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
|
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
|
|
|
if (event.key !== 'Escape') return;
|
|
|
|
|
const menuOpen = menus.some(({ menu }) => !menu.hidden);
|
|
|
|
|
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';
|
2026-10-06 23:11:07 -04:00
|
|
|
});
|
2026-10-07 23:34:59 -04:00
|
|
|
ui.reauth.addEventListener('click', () => window.location.reload());
|
2026-10-06 23:11:07 -04:00
|
|
|
|
|
|
|
|
// 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.
|
2026-10-07 23:34:59 -04:00
|
|
|
setInterval(() => {
|
2026-10-06 23:11:07 -04:00
|
|
|
if (Date.now() - state.lastActivity > 10 * 60 * 1000) return;
|
2026-10-07 23:34:59 -04:00
|
|
|
fetchToken().catch(() => {});
|
2026-10-06 23:11:07 -04:00
|
|
|
}, 3 * 60 * 1000);
|
|
|
|
|
|
|
|
|
|
window.addEventListener('online', retrySoon);
|
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
|
|
|
if (!document.hidden) retrySoon();
|
|
|
|
|
});
|
2026-10-07 23:34:59 -04:00
|
|
|
// 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));
|
2026-10-06 23:11:07 -04:00
|
|
|
setInterval(() => {
|
2026-10-07 23:34:59 -04:00
|
|
|
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
|
2026-10-07 03:03:09 -04:00
|
|
|
}, 15 * 1000);
|
2026-10-07 23:34:59 -04:00
|
|
|
// Alerts feed the Panels badge too.
|
|
|
|
|
onShared('alerts', showAlerts);
|
2026-10-07 19:26:06 -04:00
|
|
|
setInterval(() => {
|
2026-10-07 23:34:59 -04:00
|
|
|
if (!heardWithin('alerts', 62 * 1000)) refreshAlerts();
|
|
|
|
|
}, 60 * 1000);
|
2026-10-07 20:15:47 -04:00
|
|
|
// 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);
|
2026-10-07 19:26:06 -04:00
|
|
|
ui.attention.addEventListener('click', () => {
|
|
|
|
|
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
|
|
|
|
|
});
|
2026-10-06 23:11:07 -04:00
|
|
|
}
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
// ---------------------------------------------------------------- boot
|
|
|
|
|
|
|
|
|
|
async function boot() {
|
|
|
|
|
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
|
|
|
|
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
2026-10-07 23:34:59 -04:00
|
|
|
$('font-size').textContent = String(fontSize);
|
2026-10-06 23:11:07 -04:00
|
|
|
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);
|
|
|
|
|
});
|
2026-10-07 03:03:09 -04:00
|
|
|
term.onBell(() => {
|
2026-10-07 23:34:59 -04:00
|
|
|
notify.ring(state.session, ui.paneTitle.textContent || 'Claude needs your attention');
|
|
|
|
|
refreshSoon(); // Claude finished a turn: states and context just changed
|
2026-10-07 20:15:47 -04:00
|
|
|
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
|
2026-10-07 03:03:09 -04:00
|
|
|
});
|
2026-10-06 23:11:07 -04:00
|
|
|
|
|
|
|
|
let fitFrame = 0;
|
|
|
|
|
new ResizeObserver(() => {
|
|
|
|
|
if (!fitFrame) {
|
|
|
|
|
fitFrame = requestAnimationFrame(() => {
|
|
|
|
|
fitFrame = 0;
|
|
|
|
|
fit.fit();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}).observe($('terminal'));
|
|
|
|
|
fit.fit();
|
|
|
|
|
|
|
|
|
|
sessionsPanel = new SessionsPanel({
|
|
|
|
|
list: $('sessions-list'),
|
2026-10-07 03:03:09 -04:00
|
|
|
form: $('new-session-form'),
|
|
|
|
|
nameInput: $('new-session-name'),
|
2026-10-06 23:11:07 -04:00
|
|
|
select: $('project-select'),
|
|
|
|
|
refreshButton: $('sessions-refresh'),
|
2026-10-07 23:34:59 -04:00
|
|
|
newToggle: $('new-session-toggle'),
|
2026-10-06 23:11:07 -04:00
|
|
|
status: $('sessions-status'),
|
2026-10-07 03:03:09 -04:00
|
|
|
hooks: {
|
2026-10-07 23:34:59 -04:00
|
|
|
onOpen: (name) => {
|
|
|
|
|
closeDrawerIfNarrow();
|
|
|
|
|
connect(name);
|
|
|
|
|
},
|
2026-10-07 03:03:09 -04:00
|
|
|
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);
|
2026-10-07 23:34:59 -04:00
|
|
|
renderSessionChip();
|
2026-10-07 03:03:09 -04:00
|
|
|
},
|
2026-10-07 23:34:59 -04:00
|
|
|
onData: (_data, meta) => onSessionData(meta),
|
2026-10-07 03:03:09 -04:00
|
|
|
},
|
2026-10-06 23:11:07 -04:00
|
|
|
});
|
|
|
|
|
bindAskClaude($('sidebar'), (text) => {
|
2026-10-07 20:15:47 -04:00
|
|
|
// 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));
|
2026-10-07 23:34:59 -04:00
|
|
|
closeDrawerIfNarrow();
|
2026-10-06 23:11:07 -04:00
|
|
|
term.focus();
|
|
|
|
|
});
|
|
|
|
|
wireChrome();
|
2026-10-07 23:34:59 -04:00
|
|
|
wireKeybar();
|
2026-10-07 19:26:06 -04:00
|
|
|
wireUploads();
|
|
|
|
|
refreshAlerts();
|
2026-10-07 23:34:59 -04:00
|
|
|
await sessionsPanel.refresh();
|
|
|
|
|
connect(urlSession ?? defaultSession(sessionsPanel.data));
|
2026-10-06 23:11:07 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
boot();
|