Files
devdesk/web/src/sessions.js
T
jaredandClaude Opus 5.5 20134c1e58
build-deb / Lint, test, package (push) Successful in 4m59s
build-deb / Deploy to CT 126 (push) Successful in 14s
build-deb / Notify on failure (push) Skipped
feat: v0.12.0 Settings window, per-session Remote Control, delete conversations
- Settings (⋯ → Settings…, palette): theme, terminal text size, interface
  size, notifications, Remote Control & privacy. The ⋯ menu shrinks.
- Remote Control per session: registry choice > Settings default >
  DEVDESK_REMOTE_CONTROL. Switching an idle session restarts its Claude
  on the same conversation; busy sessions change at the next start.
  rc_active comes from the running process; RC tag on sessions.
- --delete-convo: remove a conversation's local transcript and subagent
  files; refused while in use. Delete… in Search past conversations.
Closes #61

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-11-03 21:23:29 -05:00

384 lines
17 KiB
JavaScript
Raw 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';
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)));
}
// How long a turn has been running: "45s", "3m", "1h 05m" (devdesk#48).
export function elapsed(sinceSeconds) {
const s = Math.max(0, Math.floor(Date.now() / 1000 - sinceSeconds));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
}
// "ctx 34% · 346k/1M": context use as a share of the window and in tokens.
const tokens = (n) => (n >= 1e6 ? `${+(n / 1e6).toFixed(n % 1e6 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
export function ctxLabel(c) {
if (c?.used_pct == null) return '';
const count = c.used_tokens != null ? ` · ${tokens(c.used_tokens)}${c.window ? `/${tokens(c.window)}` : ''}` : '';
return `ctx ${Math.floor(c.used_pct)}%${count}`;
}
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),
// onCompact(name), onCopyReply(name)
constructor({ list, form, nameInput, select, worktree, refreshButton, newToggle, status, hooks }) {
Object.assign(this, { list, form, nameInput, select, worktree, newToggle, status, hooks });
select.addEventListener('change', () => this.worktreeOption(select, worktree));
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;
}
}
// The worktree choice only makes sense for a project that is a git repository (devdesk#44).
worktreeOption(select, box) {
if (!box) return;
const git = Boolean(this.data?.projects?.find((p) => p.name === select.value && p.git && select.value));
box.closest('label').hidden = !git;
if (!git) box.checked = 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, this.worktree?.checked))) return;
this.nameInput.value = '';
this.select.value = '';
this.worktreeOption(this.select, this.worktree);
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, worktree = false) {
if (project && !(await this.act('new', name, project, ...(worktree ? ['--worktree'] : [])))) 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.');
const worktree = el('input', { type: 'checkbox' });
const worktreeLabel = el('label', { class: 'dd-check', hidden: '' }, worktree,
el('span', {}, 'Own git worktree: a separate checkout on branch devdesk/NAME, so this Claude never edits the same files as another session'));
project.addEventListener('change', () => {
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
this.worktreeOption(project, worktree);
});
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),
worktreeLabel,
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, worktree: worktree.checked };
},
});
if (result) await this.create(result.name, result.project, result.worktree);
}
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.')
+ (session.worktree ? ` Its worktree is removed if nothing in it is uncommitted; branch ${session.worktree.branch} is deleted only if it is merged.` : ''),
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 = ctxLabel(s.context);
const since = status.key === 'working' && s.status?.since ? elapsed(s.status.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,
// rate_limits means a Pro/Max plan, where cost_usd is only an API-price estimate
s.context?.model && `${s.context.model}${s.context.rate_limits ? '' : 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,
// Remote Control on: synced to claude.ai and the Claude app (devdesk#61).
s.rc_active ? el('span', { class: 'dd-tag dd-rc', title: 'Remote Control is on: this session syncs to claude.ai and the Claude app. Change it in Settings.' }, 'RC') : null,
s.worktree ? el('span', { class: 'dd-tag dd-wt', title: `Own worktree on ${s.worktree.branch}\n${s.worktree.path}` }, `⑂ ${s.worktree.branch.replace(/^devdesk\//, '')}`) : null,
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,
// The pull request of this branch (devdesk#25)
s.context?.pr?.number ? el('a', {
class: `dd-pr is-${s.context.pr.review_state || 'open'}`, href: s.context.pr.url, target: '_blank', rel: 'noopener',
title: `PR #${s.context.pr.number}${s.context.pr.review_state ? ` · ${s.context.pr.review_state.replace(/_/g, ' ')}` : ''}`,
onclick: (event) => event.stopPropagation(),
}, `PR #${s.context.pr.number}${{ approved: ' ✓', changes_requested: ' ✗' }[s.context.pr.review_state] ?? ''}`) : 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' },
// Context nearly full and Claude idle: /compact from here (devdesk#49).
s.running && pct >= 80 && status.key === 'idle'
? iconButton(`Compact (context ${Math.floor(pct)}% full)`, '⇲', () => this.hooks.onCompact?.(s.name), 'dd-compact') : null,
s.running || s.claude_session_id ? iconButton('Copy Claude\'s last reply', '⧉', () => this.hooks.onCopyReply?.(s.name)) : null,
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.'));
const scroll = this.list.scrollTop;
this.list.replaceChildren(...items);
// Keep the scroll position across polls; bring the current session into view
// when it changes.
this.list.scrollTop = scroll;
if (this.current !== this.shownCurrent) {
this.shownCurrent = this.current;
this.list.querySelector('.is-current')?.scrollIntoView({ block: 'nearest' });
}
this.projectOptions(this.select);
}
}