feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
ttyd serves a single-file xterm.js UI styled with the LotusGuild TDS (web_template base.css). Sessions live in a dedicated tmux server unit (devdesk-tmux.service), so restarting or upgrading the web unit never stops Claude. Each named session maps to a project directory. A Claude SessionStart hook records the conversation ID, so after a reboot the session resumes that exact conversation. - bin/devdesk: attach / new / ls / end / pick, with an fzf picker - ttyd requires a credential that NPM injects; origin check enabled - debian/: dh 13 with --no-stop-on-upgrade for the tmux unit, bundled sha256-verified ttyd 1.7.7, lintian overrides, man page - CI: lint, test, build, smoke-test, artifact; v* tags publish to the Gitea apt registry and create a release Refs: tinker ticket 887531976 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+366
@@ -0,0 +1,366 @@
|
||||
/* global __DEVDESK_VERSION__ */
|
||||
import { createTerminal, loadFonts } from './term.js';
|
||||
import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
|
||||
import { parseTitle, sessionFromSearch } from './protocol.js';
|
||||
import { SessionsPanel, rememberSession } from './sessions.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,
|
||||
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 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);
|
||||
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);
|
||||
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');
|
||||
// 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);
|
||||
}
|
||||
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();
|
||||
}, 30 * 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));
|
||||
|
||||
let fitFrame = 0;
|
||||
new ResizeObserver(() => {
|
||||
if (!fitFrame) {
|
||||
fitFrame = requestAnimationFrame(() => {
|
||||
fitFrame = 0;
|
||||
fit.fit();
|
||||
});
|
||||
}
|
||||
}).observe($('terminal'));
|
||||
fit.fit();
|
||||
|
||||
sessionsPanel = new SessionsPanel({
|
||||
list: $('sessions-list'),
|
||||
select: $('project-select'),
|
||||
openButton: $('project-open'),
|
||||
refreshButton: $('sessions-refresh'),
|
||||
pickerButton: $('open-picker'),
|
||||
status: $('sessions-status'),
|
||||
onOpen: (name) => connect(name),
|
||||
onPicker: () => connect(null),
|
||||
});
|
||||
bindAskClaude($('sidebar'), (text) => {
|
||||
state.conn?.sendInput(text);
|
||||
term.focus();
|
||||
});
|
||||
wireChrome();
|
||||
connect(state.session);
|
||||
}
|
||||
|
||||
boot();
|
||||
Reference in New Issue
Block a user