// 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'; import { share } from './shared.js'; import { el } from './dom.js'; import { confirmDialog, openDialog, toast } from './ui.js'; export { el }; 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. } } // Session names this browser used most recently, newest first. export function recentNames() { return readRecents().map((r) => r.name); } 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`; } // 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' }; } } export class SessionsPanel { // hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data) constructor({ list, form, nameInput, select, refreshButton, newToggle, status, hooks }) { Object.assign(this, { list, form, nameInput, select, newToggle, status, hooks }); this.current = null; this.data = null; this.pending = null; refreshButton.addEventListener('click', () => this.refresh()); newToggle.addEventListener('click', () => this.toggleForm()); form.addEventListener('submit', (event) => { event.preventDefault(); this.createFromForm(); }); } toggleForm(open = this.form.hidden) { this.form.hidden = !open; this.newToggle.setAttribute('aria-expanded', String(open)); if (open) this.nameInput.focus(); } 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); } // Concurrent callers share the request in flight (boot awaits the first list). refresh() { this.pending ??= this.load().finally(() => { this.pending = null; }); return this.pending; } // After an action: a poll already in flight may predate it, so fetch again. async reload() { await this.pending; return this.refresh(); } async load() { let fetched = false; try { this.data = await fetchSessionList(); fetched = true; share('sessions', this.data); if (!this.status.classList.contains('is-error')) this.setStatus(''); } catch (err) { this.setStatus(`list unavailable: ${err.message}`, true); } this.render(); 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 }); } // Side-channel action; failures become an error toast (devdesk#34). async act(action, ...args) { try { await sessionAction(action, ...args); return true; } catch (err) { toast.error(err.message); return false; } } async createFromForm() { const project = this.select.value; const name = slugify(this.nameInput.value) || project; if (!name) { toast.warning('Type a session name or pick a project.'); this.nameInput.focus(); return; } if (!(await this.create(name, project))) return; this.nameInput.value = ''; this.select.value = ''; this.toggleForm(false); } // 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). async create(name, project) { if (project && !(await this.act('new', name, project))) return false; this.hooks.onOpen(name); 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.'); project.addEventListener('change', () => { if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade'; }); 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), 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; } return { name: value, project: project.value }; }, }); if (result) await this.create(result.name, result.project); } async rename(name) { 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)) { renameRecent(name, next); this.hooks.onRenamed(name, next); toast.success(`Renamed ${name} → ${next}`); await this.reload(); } } async end(session) { const ok = await confirmDialog({ title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`, text: session.running ? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).' : 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.', confirmLabel: session.running ? 'End session' : 'Forget', danger: true, }); if (!ok) return; if (session.name === this.current) this.hooks.onEnding?.(session.name); if (await this.act('end', session.name)) { forgetRecent(session.name); this.hooks.onEnded(session.name); toast.success(session.running ? `Ended ${session.name}` : `Forgot ${session.name}`); await this.reload(); } else if (session.name === this.current) { this.hooks.onEndAborted?.(); } } renderSession(s) { const isCurrent = s.name === this.current; const status = sessionStatus(s); const ctx = s.context?.used_pct != null ? `ctx ${Math.floor(s.context.used_pct)}%` : ''; const since = s.status?.since ? ago(s.status.since) : ago(s.activity); const model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : ''; const meta = s.running ? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : ''] .filter(Boolean).join(' · ') : 'dormant · resumes Claude'; const pct = s.context?.used_pct; const title = [ s.dir, status.key === 'waiting' && s.status?.message, s.context?.model && `${s.context.model}${s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`, ].filter(Boolean).join('\n'); 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 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}` }), el('span', { class: 'dd-session-name' }, s.name, 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), 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), 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); this.projectOptions(this.select); } }