Files
devdesk/web/src/sessions.js
T
jaredandClaude Opus 5.5 527cb1fda2
build-deb / Lint, test, package (push) Successful in 2m0s
build-deb / Notify on failure (push) Skipped
feat: free-form sessions, rename/end from the panel, live Claude Context (0.2.0)
- 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>
2026-10-07 03:03:09 -04:00

211 lines
7.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}