// Sessions panel: server state (via `devdesk attach --list`), recently used // sessions remembered in this browser, and session actions (new/rename/end). import { fetchSessionList, sessionAction } from './connection.js'; import { slugify } from './protocol.js'; 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 []; } } function writeRecents(recents) { try { localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS))); } catch { // Storage unavailable (private mode): recents are a convenience only. } } 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))); } export function ago(epochSeconds) { 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`; } export function el(tag, attrs = {}, ...children) { const node = document.createElement(tag); for (const [key, value] of Object.entries(attrs)) { if (value === undefined || value === null || value === false) continue; if (key === 'class') node.className = value; else if (key.startsWith('on')) node.addEventListener(key.slice(2), value); else node.setAttribute(key, value === true ? '' : value); } node.append(...children.filter((c) => c !== null && c !== undefined)); return node; } export class SessionsPanel { // hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data) constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) { Object.assign(this, { list, nameInput, select, status, hooks }); this.current = null; this.data = null; this.busy = false; refreshButton.addEventListener('click', () => this.refresh()); pickerButton.addEventListener('click', () => hooks.onPicker()); form.addEventListener('submit', (event) => { event.preventDefault(); this.createFromForm(); }); } setCurrent(name) { this.current = name; if (this.data) this.render(); } setStatus(text, isError = false) { this.status.textContent = text; this.status.classList.toggle('is-error', isError); } async refresh() { if (this.busy) return; this.busy = true; try { this.data = await fetchSessionList(); if (!this.status.classList.contains('is-error')) this.setStatus(''); } catch (err) { this.setStatus(`list unavailable: ${err.message}`, true); } finally { this.busy = false; } this.render(); this.hooks.onData?.(this.data); } async act(label, action, ...args) { this.setStatus(`${label}…`); try { await sessionAction(action, ...args); this.setStatus(''); return true; } catch (err) { this.setStatus(err.message, true); return false; } } async createFromForm() { const project = this.select.value; const name = slugify(this.nameInput.value) || project; if (!name) { this.setStatus('Type a session name or pick a project.', true); this.nameInput.focus(); return; } // With a project the session starts in that folder; without one, in the default folder. if (project && !(await this.act('creating', 'new', name, project))) return; this.nameInput.value = ''; this.select.value = ''; this.hooks.onOpen(name); } async rename(name) { const answer = window.prompt(`Rename session "${name}" to:`, name); if (answer === null) return; const next = slugify(answer); if (!next || next === name) { if (!next) this.setStatus('Names use letters, numbers, - and _.', true); return; } if (await this.act('renaming', 'rename', name, next)) { renameRecent(name, next); this.hooks.onRenamed(name, next); await this.refresh(); } } async end(session) { const question = session.running ? `End session "${session.name}"?\n\nClaude in this session stops. The conversation stays in Claude's history (claude --resume).` : `Forget dormant session "${session.name}"?`; if (!window.confirm(question)) return; if (session.name === this.current) this.hooks.onEnding?.(session.name); if (await this.act(session.running ? 'ending' : 'forgetting', 'end', session.name)) { forgetRecent(session.name); this.hooks.onEnded(session.name); await this.refresh(); } else if (session.name === this.current) { this.hooks.onEndAborted?.(); } } renderSession(s) { const isCurrent = s.name === this.current; const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : ''; const meta = s.running ? `${s.attached} attached · ${ago(s.activity)}${ctx}` : 'dormant · resumes Claude'; const iconButton = (label, glyph, onclick, extra = '') => el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph); return el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}${s.running ? '' : ' is-dormant'}` }, el('button', { class: 'dd-session-open', type: 'button', title: s.dir || '', onclick: () => this.hooks.onOpen(s.name) }, el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }), el('span', { class: 'dd-session-name' }, s.name), el('span', { class: 'dd-session-meta' }, meta)), el('span', { class: 'dd-session-actions' }, 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'))); } 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) => (b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name)); const items = ordered.map((s) => this.renderSession(s)); for (const r of recentsOnly) { items.push(el('li', { class: 'dd-session-item is-recent' }, el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.hooks.onOpen(r.name) }, el('span', { class: 'lt-dot lt-dot-idle' }), el('span', { class: 'dd-session-name' }, r.name), 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(); }, }, '×')))); } if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.')); this.list.replaceChildren(...items); const projects = this.data?.projects ?? []; const chosen = this.select.value; this.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))); this.select.value = chosen; } }