Files
devdesk/web/src/sessions.js
T

328 lines
13 KiB
JavaScript
Raw Normal View History

// 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);
}
}