- devdesk attach --fs-list/--fs-read/--git-status/--git-show/--asset (libexec/devdesk-files.sh), confined to the session folder. - The state hook records files Claude edits (Edit/Write/MultiEdit/ NotebookEdit) for the Changes tab's "C" badge; rename/end carry it. - Files pane: folders, CodeMirror read-only view, sanitized markdown preview in a sandboxed iframe with inlined relative images, images, Changes tab with line counts and diff against HEAD, "→ Claude" @path, resizable split. - The editor bundle ships as /usr/share/devdesk/assets/editor.js, is fetched over ttyd on first use, verified against the page's SHA-256 and cached in IndexedDB. - Fix: the state hook's field split collapsed empty fields. Closes #19 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1057 lines
40 KiB
JavaScript
1057 lines
40 KiB
JavaScript
/* 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=<new name> 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();
|