2026-10-07 03:03:09 -04:00
// Sessions panel: server state (via `devdesk attach --list`), recently used
// sessions remembered in this browser, and session actions (new/rename/end).
2026-10-06 23:11:07 -04:00
2026-10-07 03:03:09 -04:00
import { fetchSessionList , sessionAction } from './connection.js' ;
import { slugify } from './protocol.js' ;
2026-10-07 23:34:59 -04:00
import { share } from './shared.js' ;
2026-10-08 00:32:23 -04:00
import { el } from './dom.js' ;
import { confirmDialog , openDialog , toast } from './ui.js' ;
export { el };
2026-10-06 23:11:07 -04:00
const RECENTS_KEY = 'devdesk.recents' ;
const MAX_RECENTS = 12 ;
function readRecents () {
try {
const value = JSON . parse ( localStorage . getItem ( RECENTS_KEY ) || '[]' );
return Array . isArray ( value ) ? value : [];
} catch {
return [];
}
}
2026-10-07 03:03:09 -04:00
function writeRecents ( recents ) {
2026-10-06 23:11:07 -04:00
try {
localStorage . setItem ( RECENTS_KEY , JSON . stringify ( recents . slice ( 0 , MAX_RECENTS )));
} catch {
// Storage unavailable (private mode): recents are a convenience only.
}
}
2026-10-07 23:34:59 -04:00
// Session names this browser used most recently, newest first.
export function recentNames () {
return readRecents (). map (( r ) => r . name );
}
2026-10-07 03:03:09 -04:00
export function rememberSession ( name ) {
if ( name ) writeRecents ([{ name , at : Date . now () }, ... readRecents (). filter (( r ) => r . name !== name )]);
}
function forgetRecent ( name ) {
writeRecents ( readRecents (). filter (( r ) => r . name !== name ));
}
function renameRecent ( from , to ) {
writeRecents ( readRecents (). map (( r ) => ( r . name === from ? { ... r , name : to } : r )));
}
2026-10-11 23:28:11 -04:00
// How long a turn has been running: "45s", "3m", "1h 05m" (devdesk#48).
export function elapsed ( sinceSeconds ) {
const s = Math . max ( 0 , Math . floor ( Date . now () / 1000 - sinceSeconds ));
if ( s < 60 ) return ` ${ s } s` ;
if ( s < 3600 ) return ` ${ Math . floor ( s / 60 ) } m` ;
return ` ${ Math . floor ( s / 3600 ) } h ${ String ( Math . floor (( s % 3600 ) / 60 )). padStart ( 2 , '0' ) } m` ;
}
2026-10-11 23:07:04 -04:00
// "ctx 34% · 346k/1M": context use as a share of the window and in tokens.
const tokens = ( n ) => ( n >= 1e6 ? ` ${ + ( n / 1e6 ). toFixed ( n % 1e6 ? 1 : 0 ) } M` : n >= 1000 ? ` ${ Math . round ( n / 1000 ) } k` : String ( n ));
export function ctxLabel ( c ) {
if ( c ? . used_pct == null ) return '' ;
const count = c . used_tokens != null ? ` · ${ tokens ( c . used_tokens ) }${ c . window ? `/ ${ tokens ( c . window ) } ` : '' } ` : '' ;
return `ctx ${ Math . floor ( c . used_pct ) } % ${ count } ` ;
}
2026-10-07 03:03:09 -04:00
export function ago ( epochSeconds ) {
2026-10-06 23:11:07 -04:00
if ( ! epochSeconds ) return '' ;
const s = Math . max ( 0 , Date . now () / 1000 - epochSeconds );
if ( s < 90 ) return 'now' ;
if ( s < 3600 ) return ` ${ Math . floor ( s / 60 ) } m` ;
if ( s < 86400 ) return ` ${ Math . floor ( s / 3600 ) } h` ;
return ` ${ Math . floor ( s / 86400 ) } d` ;
}
2026-10-07 19:26:06 -04:00
// What Claude is doing in a session, from the devdesk-state-hook snapshot.
// Returns { key, label, dot } for display; key is one of
// waiting | working | idle | exited | running | dormant.
export function sessionStatus ( s ) {
if ( ! s ? . running ) return { key : 'dormant' , label : 'dormant' , dot : 'lt-dot-idle' };
switch ( s . status ? . state ) {
case 'waiting' : return { key : 'waiting' , label : 'needs you' , dot : 'lt-dot-down' };
case 'working' : return { key : 'working' , label : 'working' , dot : 'lt-dot-warn' };
case 'idle' : return { key : 'idle' , label : 'idle' , dot : 'lt-dot-up' };
case 'exited' : return { key : 'exited' , label : 'Claude exited' , dot : 'lt-dot-idle' };
default : return { key : 'running' , label : 'running' , dot : 'lt-dot-up' };
}
}
2026-10-06 23:11:07 -04:00
export class SessionsPanel {
2026-10-11 23:28:11 -04:00
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data),
// onCompact(name), onCopyReply(name)
constructor ({ list , form , nameInput , select , worktree , refreshButton , newToggle , status , hooks }) {
Object . assign ( this , { list , form , nameInput , select , worktree , newToggle , status , hooks });
select . addEventListener ( 'change' , () => this . worktreeOption ( select , worktree ));
2026-10-06 23:11:07 -04:00
this . current = null ;
this . data = null ;
2026-10-07 23:34:59 -04:00
this . pending = null ;
2026-10-06 23:11:07 -04:00
refreshButton . addEventListener ( 'click' , () => this . refresh ());
2026-10-07 23:34:59 -04:00
newToggle . addEventListener ( 'click' , () => this . toggleForm ());
2026-10-07 03:03:09 -04:00
form . addEventListener ( 'submit' , ( event ) => {
event . preventDefault ();
this . createFromForm ();
2026-10-06 23:11:07 -04:00
});
}
2026-10-07 23:34:59 -04:00
toggleForm ( open = this . form . hidden ) {
this . form . hidden = ! open ;
this . newToggle . setAttribute ( 'aria-expanded' , String ( open ));
if ( open ) this . nameInput . focus ();
}
2026-10-06 23:11:07 -04:00
setCurrent ( name ) {
this . current = name ;
if ( this . data ) this . render ();
}
2026-10-07 03:03:09 -04:00
setStatus ( text , isError = false ) {
this . status . textContent = text ;
this . status . classList . toggle ( 'is-error' , isError );
}
2026-10-07 23:34:59 -04:00
// Concurrent callers share the request in flight (boot awaits the first list).
refresh () {
this . pending ??= this . load (). finally (() => { this . pending = null ; });
return this . pending ;
}
2026-10-08 00:32:23 -04:00
// After an action: a poll already in flight may predate it, so fetch again.
async reload () {
await this . pending ;
return this . refresh ();
}
2026-10-07 23:34:59 -04:00
async load () {
let fetched = false ;
2026-10-06 23:11:07 -04:00
try {
this . data = await fetchSessionList ();
2026-10-07 23:34:59 -04:00
fetched = true ;
share ( 'sessions' , this . data );
2026-10-07 03:03:09 -04:00
if ( ! this . status . classList . contains ( 'is-error' )) this . setStatus ( '' );
2026-10-06 23:11:07 -04:00
} catch ( err ) {
2026-10-07 03:03:09 -04:00
this . setStatus ( `list unavailable: ${ err . message } ` , true );
2026-10-06 23:11:07 -04:00
}
this . render ();
2026-10-07 23:34:59 -04:00
this . hooks . onData ? .( this . data , { fetched });
}
// A list another tab fetched (devdesk#8).
accept ( data ) {
this . data = data ;
this . render ();
this . hooks . onData ? .( this . data , { fetched : false });
2026-10-07 03:03:09 -04:00
}
2026-10-08 00:32:23 -04:00
// Side-channel action; failures become an error toast (devdesk#34).
async act ( action , ... args ) {
2026-10-07 03:03:09 -04:00
try {
await sessionAction ( action , ... args );
return true ;
} catch ( err ) {
2026-10-08 00:32:23 -04:00
toast . error ( err . message );
2026-10-07 03:03:09 -04:00
return false ;
}
}
2026-10-11 23:28:11 -04:00
// The worktree choice only makes sense for a project that is a git repository (devdesk#44).
worktreeOption ( select , box ) {
if ( ! box ) return ;
const git = Boolean ( this . data ? . projects ? . find (( p ) => p . name === select . value && p . git && select . value ));
box . closest ( 'label' ). hidden = ! git ;
if ( ! git ) box . checked = false ;
}
2026-10-07 03:03:09 -04:00
async createFromForm () {
const project = this . select . value ;
const name = slugify ( this . nameInput . value ) || project ;
if ( ! name ) {
2026-10-08 00:32:23 -04:00
toast . warning ( 'Type a session name or pick a project.' );
2026-10-07 03:03:09 -04:00
this . nameInput . focus ();
return ;
}
2026-10-11 23:28:11 -04:00
if ( ! ( await this . create ( name , project , this . worktree ? . checked ))) return ;
2026-10-07 03:03:09 -04:00
this . nameInput . value = '' ;
this . select . value = '' ;
2026-10-11 23:28:11 -04:00
this . worktreeOption ( this . select , this . worktree );
2026-10-07 23:34:59 -04:00
this . toggleForm ( false );
2026-10-08 00:32:23 -04:00
}
// With a project the session starts in that folder; without one, opening it
// creates it in the default folder (or its own project folder, by name).
2026-10-11 23:28:11 -04:00
async create ( name , project , worktree = false ) {
if ( project && ! ( await this . act ( 'new' , name , project , ...( worktree ? [ '--worktree' ] : [])))) return false ;
2026-10-07 03:03:09 -04:00
this . hooks . onOpen ( name );
2026-10-08 00:32:23 -04:00
return true ;
}
projectOptions ( select ) {
const projects = this . data ? . projects ?? [];
const chosen = select . value ;
select . replaceChildren (
el ( 'option' , { value : '' }, `No project ( ${ this . data ? . root ?? 'default folder' } )` ),
... projects . filter (( p ) => p . name !== this . data ? . default )
. map (( p ) => el ( 'option' , { value : p . name , title : p . dir }, p . name )));
select . value = chosen ;
}
// "+ New session…" from the switcher or the command palette (devdesk#33).
async newDialog () {
const name = el ( 'input' , { class : 'lt-input' , maxlength : '64' , placeholder : 'e.g. pbs-upgrade' , spellcheck : 'false' , autocomplete : 'off' , 'aria-describedby' : 'dd-new-hint' });
const project = el ( 'select' , { class : 'lt-select' });
this . projectOptions ( project );
const hint = el ( 'p' , { class : 'dd-dialog-hint' , id : 'dd-new-hint' }, 'Letters, numbers, - and _. Any name works; pick a project to start in its folder.' );
2026-10-11 23:28:11 -04:00
const worktree = el ( 'input' , { type : 'checkbox' });
const worktreeLabel = el ( 'label' , { class : 'dd-check' , hidden : '' }, worktree ,
el ( 'span' , {}, 'Own git worktree: a separate checkout on branch devdesk/NAME, so this Claude never edits the same files as another session' ));
2026-10-08 00:32:23 -04:00
project . addEventListener ( 'change' , () => {
if ( ! name . value . trim ()) name . placeholder = project . value || 'e.g. pbs-upgrade' ;
2026-10-11 23:28:11 -04:00
this . worktreeOption ( project , worktree );
2026-10-08 00:32:23 -04:00
});
const result = await openDialog ({
title : 'New session' ,
body : el ( 'div' , { class : 'dd-dialog-fields' },
el ( 'label' , { class : 'dd-field' }, el ( 'span' , {}, 'Name' ), name ),
el ( 'label' , { class : 'dd-field' }, el ( 'span' , {}, 'Project folder' ), project ),
2026-10-11 23:28:11 -04:00
worktreeLabel ,
2026-10-08 00:32:23 -04:00
hint ),
actions : [{ label : 'Cancel' }, { label : 'Open' , primary : true }],
submit : () => {
const value = slugify ( name . value ) || project . value ;
if ( ! value ) {
hint . textContent = 'Type a name or pick a project.' ;
hint . classList . add ( 'is-error' );
name . focus ();
return null ;
}
2026-10-11 23:28:11 -04:00
return { name : value , project : project . value , worktree : worktree . checked };
2026-10-08 00:32:23 -04:00
},
});
2026-10-11 23:28:11 -04:00
if ( result ) await this . create ( result . name , result . project , result . worktree );
2026-10-07 03:03:09 -04:00
}
async rename ( name ) {
2026-10-08 00:32:23 -04:00
const input = el ( 'input' , { class : 'lt-input' , maxlength : '64' , value : name , spellcheck : 'false' , autocomplete : 'off' , 'aria-describedby' : 'dd-rename-hint' });
const hint = el ( 'p' , { class : 'dd-dialog-hint' , id : 'dd-rename-hint' }, 'Claude keeps running; the conversation moves with the session.' );
const next = await openDialog ({
title : `Rename ${ name } ` ,
size : 'sm' ,
body : el ( 'div' , { class : 'dd-dialog-fields' }, el ( 'label' , { class : 'dd-field' }, el ( 'span' , {}, 'New name' ), input ), hint ),
actions : [{ label : 'Cancel' }, { label : 'Rename' , primary : true }],
onOpen : () => {
input . select ();
return input ;
},
submit : () => {
const value = slugify ( input . value );
if ( ! value ) {
hint . textContent = 'Names use letters, numbers, - and _.' ;
hint . classList . add ( 'is-error' );
return null ;
}
if ( value !== name && this . data ? . sessions ? . some (( x ) => x . name === value )) {
hint . textContent = `A session called ${ value } already exists.` ;
hint . classList . add ( 'is-error' );
return null ;
}
return value ;
},
});
if ( ! next || next === name ) return ;
if ( await this . act ( 'rename' , name , next )) {
2026-10-07 03:03:09 -04:00
renameRecent ( name , next );
this . hooks . onRenamed ( name , next );
2026-10-08 00:32:23 -04:00
toast . success ( `Renamed ${ name } → ${ next } ` );
await this . reload ();
2026-10-07 03:03:09 -04:00
}
}
async end ( session ) {
2026-10-08 00:32:23 -04:00
const ok = await confirmDialog ({
title : session . running ? `End ${ session . name } ?` : `Forget ${ session . name } ?` ,
2026-10-11 23:28:11 -04:00
text : ( session . running
2026-10-08 00:32:23 -04:00
? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).'
2026-10-11 23:28:11 -04:00
: 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.' )
+ ( session . worktree ? ` Its worktree is removed if nothing in it is uncommitted; branch ${ session . worktree . branch } is deleted only if it is merged.` : '' ),
2026-10-08 00:32:23 -04:00
confirmLabel : session . running ? 'End session' : 'Forget' ,
danger : true ,
});
if ( ! ok ) return ;
2026-10-07 03:03:09 -04:00
if ( session . name === this . current ) this . hooks . onEnding ? .( session . name );
2026-10-08 00:32:23 -04:00
if ( await this . act ( 'end' , session . name )) {
2026-10-07 03:03:09 -04:00
forgetRecent ( session . name );
this . hooks . onEnded ( session . name );
2026-10-08 00:32:23 -04:00
toast . success ( session . running ? `Ended ${ session . name } ` : `Forgot ${ session . name } ` );
await this . reload ();
2026-10-07 03:03:09 -04:00
} else if ( session . name === this . current ) {
this . hooks . onEndAborted ? .();
}
}
renderSession ( s ) {
const isCurrent = s . name === this . current ;
2026-10-07 19:26:06 -04:00
const status = sessionStatus ( s );
2026-10-11 23:07:04 -04:00
const ctx = ctxLabel ( s . context );
2026-10-11 23:28:11 -04:00
const since = status . key === 'working' && s . status ? . since ? elapsed ( s . status . since )
: s . status ? . since ? ago ( s . status . since ) : ago ( s . activity );
2026-10-07 23:34:59 -04:00
const model = isCurrent && s . context ? . model ? s . context . model . replace ( /^Claude\s+/i , '' ) : '' ;
2026-10-07 03:03:09 -04:00
const meta = s . running
2026-10-07 23:34:59 -04:00
? [ ` ${ status . label }${ since && since !== 'now' ? ` ${ since } ` : '' } ` , ctx , model , s . attached ? ` ${ s . attached } open` : '' ]
2026-10-07 19:26:06 -04:00
. filter ( Boolean ). join ( ' · ' )
2026-10-07 03:03:09 -04:00
: 'dormant · resumes Claude' ;
2026-10-07 23:34:59 -04:00
const pct = s . context ? . used_pct ;
const title = [
s . dir ,
status . key === 'waiting' && s . status ? . message ,
2026-10-11 22:07:39 -04:00
// rate_limits means a Pro/Max plan, where cost_usd is only an API-price estimate
s . context ? . model && ` ${ s . context . model }${ s . context . rate_limits ? '' : s . context . cost_usd != null ? ` · $ ${ s . context . cost_usd . toFixed ( 2 ) } ` : '' } ` ,
2026-10-07 23:34:59 -04:00
]. filter ( Boolean ). join ( '\n' );
2026-10-07 03:03:09 -04:00
const iconButton = ( label , glyph , onclick , extra = '' ) =>
el ( 'button' , { class : `dd-icon-btn ${ extra } ` , type : 'button' , title : label , 'aria-label' : ` ${ label } ${ s . name } ` , onclick }, glyph );
2026-10-07 19:26:06 -04:00
return el ( 'li' , { class : `dd-session-item is- ${ status . key }${ isCurrent ? ' is-current' : '' } ` },
el ( 'button' , { class : 'dd-session-open' , type : 'button' , title , onclick : () => this . hooks . onOpen ( s . name ) },
el ( 'span' , { class : `lt-dot ${ status . dot } ` }),
2026-10-10 20:37:46 -04:00
el ( 'span' , { class : 'dd-session-name' }, s . name ,
2026-11-03 21:23:29 -05:00
// Remote Control on: synced to claude.ai and the Claude app (devdesk#61).
s . rc_active ? el ( 'span' , { class : 'dd-tag dd-rc' , title : 'Remote Control is on: this session syncs to claude.ai and the Claude app. Change it in Settings.' }, 'RC' ) : null ,
2026-10-11 23:28:11 -04:00
s . worktree ? el ( 'span' , { class : 'dd-tag dd-wt' , title : `Own worktree on ${ s . worktree . branch } \n ${ s . worktree . path } ` }, `⑂ ${ s . worktree . branch . replace ( /^devdesk\// , '' ) } ` ) : null ,
2026-10-10 21:45:26 -04:00
s . tags ? . length ? el ( 'span' , { class : 'dd-tags' , title : 'Hosts this session worked on' }, ... s . tags . slice ( 0 , 3 ). map (( t ) => el ( 'span' , { class : 'dd-tag' }, t ))) : null ,
// The pull request of this branch (devdesk#25)
s . context ? . pr ? . number ? el ( 'a' , {
class : `dd-pr is- ${ s . context . pr . review_state || 'open' } ` , href : s . context . pr . url , target : '_blank' , rel : 'noopener' ,
title : `PR # ${ s . context . pr . number }${ s . context . pr . review_state ? ` · ${ s . context . pr . review_state . replace ( /_/g , ' ' ) } ` : '' } ` ,
onclick : ( event ) => event . stopPropagation (),
}, `PR # ${ s . context . pr . number }${ { approved : ' ✓' , changes_requested : ' ✗' } [s.context.pr.review_state] ?? ''}` ) : null ),
2026-10-07 23:34:59 -04:00
el ( 'span' , { class : 'dd-session-meta' }, meta ),
pct != null ? el ( 'span' , { class : `dd-ctx ${ pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : '' } ` , 'aria-hidden' : 'true' },
el ( 'span' , { class : 'dd-ctx-fill' , style : `width: ${ Math . min ( 100 , pct ) } %` })) : null ),
2026-10-07 03:03:09 -04:00
el ( 'span' , { class : 'dd-session-actions' },
2026-10-11 23:28:11 -04:00
// Context nearly full and Claude idle: /compact from here (devdesk#49).
s . running && pct >= 80 && status . key === 'idle'
? iconButton ( `Compact (context ${ Math . floor ( pct ) } % full)` , '⇲' , () => this . hooks . onCompact ? .( s . name ), 'dd-compact' ) : null ,
s . running || s . claude_session_id ? iconButton ( 'Copy Claude\'s last reply' , '⧉' , () => this . hooks . onCopyReply ? .( s . name )) : null ,
2026-10-07 03:03:09 -04:00
iconButton ( 'Rename' , '✎' , () => this . rename ( s . name )),
el ( 'a' , {
class : 'dd-icon-btn' , href : `?arg= ${ encodeURIComponent ( s . name ) } ` , target : '_blank' , rel : 'noopener' ,
title : 'Open in a new tab' , 'aria-label' : `Open ${ s . name } in a new tab` ,
}, '↗' ),
iconButton ( s . running ? 'End session' : 'Forget' , '× ' , () => this . end ( s ), 'dd-danger' )));
2026-10-06 23:11:07 -04:00
}
render () {
const sessions = this . data ? . sessions ?? [];
const known = new Set ( sessions . map (( s ) => s . name ));
const recentsOnly = readRecents (). filter (( r ) => ! known . has ( r . name )). slice ( 0 , 5 );
const ordered = [... sessions ]. sort (( a , b ) =>
2026-10-07 03:03:09 -04:00
( b . running - a . running ) || (( b . activity || 0 ) - ( a . activity || 0 )) || a . name . localeCompare ( b . name ));
const items = ordered . map (( s ) => this . renderSession ( s ));
2026-10-06 23:11:07 -04:00
for ( const r of recentsOnly ) {
2026-10-07 03:03:09 -04:00
items . push ( el ( 'li' , { class : 'dd-session-item is-recent' },
el ( 'button' , { class : 'dd-session-open' , type : 'button' , onclick : () => this . hooks . onOpen ( r . name ) },
2026-10-06 23:11:07 -04:00
el ( 'span' , { class : 'lt-dot lt-dot-idle' }),
el ( 'span' , { class : 'dd-session-name' }, r . name ),
2026-10-07 03:03:09 -04:00
el ( 'span' , { class : 'dd-session-meta' }, 'recent in this browser' )),
el ( 'span' , { class : 'dd-session-actions' },
el ( 'button' , {
class : 'dd-icon-btn dd-danger' , type : 'button' , title : 'Remove from recents' ,
onclick : () => { forgetRecent ( r . name ); this . render (); },
}, '× ' ))));
2026-10-06 23:11:07 -04:00
}
2026-10-07 03:03:09 -04:00
if ( ! items . length ) items . push ( el ( 'li' , { class : 'dd-empty' }, 'No sessions yet.' ));
2026-10-22 20:53:34 -04:00
const scroll = this . list . scrollTop ;
2026-10-07 03:03:09 -04:00
this . list . replaceChildren (... items );
2026-10-22 20:53:34 -04:00
// Keep the scroll position across polls; bring the current session into view
// when it changes.
this . list . scrollTop = scroll ;
if ( this . current !== this . shownCurrent ) {
this . shownCurrent = this . current ;
this . list . querySelector ( '.is-current' ) ? . scrollIntoView ({ block : 'nearest' });
}
2026-10-06 23:11:07 -04:00
2026-10-08 00:32:23 -04:00
this . projectOptions ( this . select );
2026-10-06 23:11:07 -04:00
}
}