- Sessions no longer need a project folder: any name works and starts in DEVDESK_DEFAULT_DIR unless it matches a project; `devdesk new NAME [PROJECT]`. - Rename (✎) and end/forget (×) sessions from the Sessions panel, plus `devdesk rename`. The browser runs them as `devdesk attach --new|--rename|--end` through ttyd. Renames keep the running Claude and its recorded conversation: the hook, the run-wrapper and the statusline ask tmux for the current name. - Claude Context panel is live: devdesk-statusline snapshots model, context-window use, cost and edits per session, which --list reports; Claude's footer gets a one-line summary. The panel now sits under Sessions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
7.8 KiB
JavaScript
211 lines
7.8 KiB
JavaScript
// 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;
|
||
}
|
||
}
|