2026-10-06 23:11:07 -04:00
/* global __DEVDESK_VERSION__ */
2026-10-08 00:32:23 -04:00
import { THEMES , createTerminal , loadFonts } from './term.js' ;
import { getTheme , initTheme , onThemeChange , toggleTheme } from './theme.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-08 00:32:23 -04:00
import { SessionsPanel , 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-08 00:32:23 -04:00
import { CommandPalette , dialogOpen , openDialog , toast } from './ui.js' ;
2026-10-08 00:51:01 -04:00
import { Inbox } from './inbox.js' ;
2026-10-08 00:32:23 -04:00
import { el } from './dom.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
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-08 00:32:23 -04:00
let palette ;
2026-10-08 00:51:01 -04:00
let inbox ;
2026-10-08 00:32:23 -04:00
let lastRepo = null ;
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 ();
2026-10-08 00:51:01 -04:00
const prevStatus = new Map ();
2026-10-07 23:34:59 -04:00
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 ));
2026-10-08 00:51:01 -04:00
if ( fetched ) recordTransitions ( data ? . sessions ?? []);
2026-10-07 23:34:59 -04:00
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 ();
}
2026-10-08 00:51:01 -04:00
// 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` });
}
}
}
2026-10-07 23:34:59 -04:00
function renderPanelsBadge () {
2026-10-08 00:51:01 -04:00
inbox ? . setLive ({ waiting : waitingOthers , alerts : lastAlerts ? . alerts ?? [] });
2026-10-07 23:34:59 -04:00
}
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 ) {
2026-10-08 00:32:23 -04:00
toast . warning ( 'Open a session first: files are attached to the current session.' );
2026-10-07 19:26:06 -04:00
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 ) {
2026-10-08 00:32:23 -04:00
uploadUi . hidden = true ;
toast . error ( ` ${ file . name || 'file' } : ${ error . message } ` );
2026-10-07 19:26:06 -04:00
return ;
}
}
2026-10-08 00:32:23 -04:00
uploadUi . hidden = true ;
2026-10-07 19:26:06 -04:00
if ( session !== state . session ) return ;
// A pasted image path makes Claude attach the image itself.
term . paste ( ` ${ paths . join ( ' ' ) } ` );
term . focus ();
2026-10-08 00:32:23 -04:00
toast . success ( paths . length === 1 ? `Attached ${ paths [ 0 ]. split ( '/' ). pop () } ` : `Attached ${ paths . length } files` );
2026-10-07 19:26:06 -04:00
}
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 ) {
2026-10-08 00:32:23 -04:00
lastRepo = null ;
2026-10-07 13:09:32 -04:00
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 );
2026-10-08 00:32:23 -04:00
lastRepo = repo ;
2026-10-07 13:09:32 -04:00
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 )) {
2026-10-08 00:32:23 -04:00
const text = `CI ${ ci === 'success' ? 'passed' : 'failed' } on ${ repo . branch } @ ${ repo . sha } ` ;
notify . ring ( session , text );
toast [ ci === 'success' ? 'success' : 'error' ]( text );
2026-10-08 00:51:01 -04:00
inbox . record ({ kind : ci === 'success' ? 'ci-pass' : 'ci-fail' , session , text : ` ${ session } : ${ text } ` , url : repo . ci ? . url });
2026-10-07 13:09:32 -04:00
}
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' );
}
2026-10-08 00:51:01 -04:00
if ( inbox ? . isOpen ) inbox . setOpen ( false );
2026-10-07 23:34:59 -04:00
}
// 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' ,
2026-10-08 00:32:23 -04:00
onclick : choose (() => sessionsPanel . newDialog ()),
2026-10-07 23:34:59 -04:00
}, '+ 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 ) {
2026-10-08 00:32:23 -04:00
if ( ! Number . isFinite ( size )) return ;
2026-10-06 23:11:07 -04:00
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 ();
}
2026-10-08 00:32:23 -04:00
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' ) });
}
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+⇧ 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 ());
}
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 (() => {});
}
2026-10-06 23:11:07 -04:00
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-08 00:32:23 -04:00
await toggleNotifications ();
2026-10-06 23:11:07 -04:00
renderNotifyButton ();
});
2026-10-08 00:32:23 -04:00
$ ( 'fullscreen-toggle' ). addEventListener ( 'click' , () => {
2026-10-07 23:34:59 -04:00
closeMenus ();
2026-10-08 00:32:23 -04:00
toggleFullscreen ();
2026-10-06 23:11:07 -04:00
});
2026-10-08 00:32:23 -04:00
$ ( 'help-open' ). addEventListener ( 'click' , () => {
closeMenus ();
showHelp ();
});
$ ( 'footer-help' ). addEventListener ( 'click' , showHelp );
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 === 'KeyP' ) {
event . preventDefault ();
event . stopPropagation ();
if ( palette . isOpen ) palette . close ();
else if ( ! dialogOpen ()) palette . open ();
}
}, true );
2026-10-08 00:51:01 -04:00
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 ());
2026-10-07 23:34:59 -04:00
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 ) => {
2026-10-08 00:32:23 -04:00
if ( event . key !== 'Escape' || dialogOpen ()) return ;
2026-10-08 00:51:01 -04:00
const menuOpen = menus . some (({ menu }) => ! menu . hidden ) || inbox . isOpen ;
2026-10-07 23:34:59 -04:00
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 () {
2026-10-08 00:32:23 -04:00
initTheme ();
2026-10-06 23:11:07 -04:00
$ ( '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 ,
2026-10-08 00:32:23 -04:00
theme : getTheme (),
2026-10-06 23:11:07 -04:00
onShiftEnter : () => state . conn ? . sendInput ( '\n' ),
}));
2026-10-08 00:32:23 -04:00
onThemeChange (( theme ) => { term . options . theme = THEMES [ theme ]; });
$ ( 'theme-toggle' ). addEventListener ( 'click' , toggleTheme );
2026-10-06 23:11:07 -04:00
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 ();