2026-10-07 03:03:09 -04:00
|
|
|
|
// Sessions panel: server state (via `devdesk attach --list`), recently used
|
|
|
|
|
|
// sessions remembered in this browser, and session actions (new/rename/end).
|
2026-10-06 23:11:07 -04:00
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
|
import { fetchSessionList, sessionAction } from './connection.js';
|
|
|
|
|
|
import { slugify } from './protocol.js';
|
2026-10-07 23:34:59 -04:00
|
|
|
|
import { share } from './shared.js';
|
2026-10-08 00:32:23 -04:00
|
|
|
|
import { el } from './dom.js';
|
|
|
|
|
|
import { confirmDialog, openDialog, toast } from './ui.js';
|
|
|
|
|
|
|
|
|
|
|
|
export { el };
|
2026-10-06 23:11:07 -04:00
|
|
|
|
|
|
|
|
|
|
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 [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
|
function writeRecents(recents) {
|
2026-10-06 23:11:07 -04:00
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Storage unavailable (private mode): recents are a convenience only.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
|
// Session names this browser used most recently, newest first.
|
|
|
|
|
|
export function recentNames() {
|
|
|
|
|
|
return readRecents().map((r) => r.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
|
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)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 23:28:11 -04:00
|
|
|
|
// 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`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 23:07:04 -04:00
|
|
|
|
// "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}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
|
export function ago(epochSeconds) {
|
2026-10-06 23:11:07 -04:00
|
|
|
|
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`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 19:26:06 -04:00
|
|
|
|
// 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' };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
|
export class SessionsPanel {
|
2026-10-11 23:28:11 -04:00
|
|
|
|
// 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));
|
2026-10-06 23:11:07 -04:00
|
|
|
|
this.current = null;
|
|
|
|
|
|
this.data = null;
|
2026-10-07 23:34:59 -04:00
|
|
|
|
this.pending = null;
|
2026-10-06 23:11:07 -04:00
|
|
|
|
refreshButton.addEventListener('click', () => this.refresh());
|
2026-10-07 23:34:59 -04:00
|
|
|
|
newToggle.addEventListener('click', () => this.toggleForm());
|
2026-10-07 03:03:09 -04:00
|
|
|
|
form.addEventListener('submit', (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
this.createFromForm();
|
2026-10-06 23:11:07 -04:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
|
toggleForm(open = this.form.hidden) {
|
|
|
|
|
|
this.form.hidden = !open;
|
|
|
|
|
|
this.newToggle.setAttribute('aria-expanded', String(open));
|
|
|
|
|
|
if (open) this.nameInput.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
|
setCurrent(name) {
|
|
|
|
|
|
this.current = name;
|
|
|
|
|
|
if (this.data) this.render();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
|
setStatus(text, isError = false) {
|
|
|
|
|
|
this.status.textContent = text;
|
|
|
|
|
|
this.status.classList.toggle('is-error', isError);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
|
// Concurrent callers share the request in flight (boot awaits the first list).
|
|
|
|
|
|
refresh() {
|
|
|
|
|
|
this.pending ??= this.load().finally(() => { this.pending = null; });
|
|
|
|
|
|
return this.pending;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 00:32:23 -04:00
|
|
|
|
// After an action: a poll already in flight may predate it, so fetch again.
|
|
|
|
|
|
async reload() {
|
|
|
|
|
|
await this.pending;
|
|
|
|
|
|
return this.refresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 23:34:59 -04:00
|
|
|
|
async load() {
|
|
|
|
|
|
let fetched = false;
|
2026-10-06 23:11:07 -04:00
|
|
|
|
try {
|
|
|
|
|
|
this.data = await fetchSessionList();
|
2026-10-07 23:34:59 -04:00
|
|
|
|
fetched = true;
|
|
|
|
|
|
share('sessions', this.data);
|
2026-10-07 03:03:09 -04:00
|
|
|
|
if (!this.status.classList.contains('is-error')) this.setStatus('');
|
2026-10-06 23:11:07 -04:00
|
|
|
|
} catch (err) {
|
2026-10-07 03:03:09 -04:00
|
|
|
|
this.setStatus(`list unavailable: ${err.message}`, true);
|
2026-10-06 23:11:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
this.render();
|
2026-10-07 23:34:59 -04:00
|
|
|
|
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 });
|
2026-10-07 03:03:09 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 00:32:23 -04:00
|
|
|
|
// Side-channel action; failures become an error toast (devdesk#34).
|
|
|
|
|
|
async act(action, ...args) {
|
2026-10-07 03:03:09 -04:00
|
|
|
|
try {
|
|
|
|
|
|
await sessionAction(action, ...args);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (err) {
|
2026-10-08 00:32:23 -04:00
|
|
|
|
toast.error(err.message);
|
2026-10-07 03:03:09 -04:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 23:28:11 -04:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
|
async createFromForm() {
|
|
|
|
|
|
const project = this.select.value;
|
|
|
|
|
|
const name = slugify(this.nameInput.value) || project;
|
|
|
|
|
|
if (!name) {
|
2026-10-08 00:32:23 -04:00
|
|
|
|
toast.warning('Type a session name or pick a project.');
|
2026-10-07 03:03:09 -04:00
|
|
|
|
this.nameInput.focus();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-11 23:28:11 -04:00
|
|
|
|
if (!(await this.create(name, project, this.worktree?.checked))) return;
|
2026-10-07 03:03:09 -04:00
|
|
|
|
this.nameInput.value = '';
|
|
|
|
|
|
this.select.value = '';
|
2026-10-11 23:28:11 -04:00
|
|
|
|
this.worktreeOption(this.select, this.worktree);
|
2026-10-07 23:34:59 -04:00
|
|
|
|
this.toggleForm(false);
|
2026-10-08 00:32:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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).
|
2026-10-11 23:28:11 -04:00
|
|
|
|
async create(name, project, worktree = false) {
|
|
|
|
|
|
if (project && !(await this.act('new', name, project, ...(worktree ? ['--worktree'] : [])))) return false;
|
2026-10-07 03:03:09 -04:00
|
|
|
|
this.hooks.onOpen(name);
|
2026-10-08 00:32:23 -04:00
|
|
|
|
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.');
|
2026-10-11 23:28:11 -04:00
|
|
|
|
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'));
|
2026-10-08 00:32:23 -04:00
|
|
|
|
project.addEventListener('change', () => {
|
|
|
|
|
|
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
|
2026-10-11 23:28:11 -04:00
|
|
|
|
this.worktreeOption(project, worktree);
|
2026-10-08 00:32:23 -04:00
|
|
|
|
});
|
|
|
|
|
|
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),
|
2026-10-11 23:28:11 -04:00
|
|
|
|
worktreeLabel,
|
2026-10-08 00:32:23 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-10-11 23:28:11 -04:00
|
|
|
|
return { name: value, project: project.value, worktree: worktree.checked };
|
2026-10-08 00:32:23 -04:00
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-10-11 23:28:11 -04:00
|
|
|
|
if (result) await this.create(result.name, result.project, result.worktree);
|
2026-10-07 03:03:09 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async rename(name) {
|
2026-10-08 00:32:23 -04:00
|
|
|
|
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)) {
|
2026-10-07 03:03:09 -04:00
|
|
|
|
renameRecent(name, next);
|
|
|
|
|
|
this.hooks.onRenamed(name, next);
|
2026-10-08 00:32:23 -04:00
|
|
|
|
toast.success(`Renamed ${name} → ${next}`);
|
|
|
|
|
|
await this.reload();
|
2026-10-07 03:03:09 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async end(session) {
|
2026-10-08 00:32:23 -04:00
|
|
|
|
const ok = await confirmDialog({
|
|
|
|
|
|
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
|
2026-10-11 23:28:11 -04:00
|
|
|
|
text: (session.running
|
2026-10-08 00:32:23 -04:00
|
|
|
|
? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).'
|
2026-10-11 23:28:11 -04:00
|
|
|
|
: '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.` : ''),
|
2026-10-08 00:32:23 -04:00
|
|
|
|
confirmLabel: session.running ? 'End session' : 'Forget',
|
|
|
|
|
|
danger: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!ok) return;
|
2026-10-07 03:03:09 -04:00
|
|
|
|
if (session.name === this.current) this.hooks.onEnding?.(session.name);
|
2026-10-08 00:32:23 -04:00
|
|
|
|
if (await this.act('end', session.name)) {
|
2026-10-07 03:03:09 -04:00
|
|
|
|
forgetRecent(session.name);
|
|
|
|
|
|
this.hooks.onEnded(session.name);
|
2026-10-08 00:32:23 -04:00
|
|
|
|
toast.success(session.running ? `Ended ${session.name}` : `Forgot ${session.name}`);
|
|
|
|
|
|
await this.reload();
|
2026-10-07 03:03:09 -04:00
|
|
|
|
} else if (session.name === this.current) {
|
|
|
|
|
|
this.hooks.onEndAborted?.();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderSession(s) {
|
|
|
|
|
|
const isCurrent = s.name === this.current;
|
2026-10-07 19:26:06 -04:00
|
|
|
|
const status = sessionStatus(s);
|
2026-10-11 23:07:04 -04:00
|
|
|
|
const ctx = ctxLabel(s.context);
|
2026-10-11 23:28:11 -04:00
|
|
|
|
const since = status.key === 'working' && s.status?.since ? elapsed(s.status.since)
|
|
|
|
|
|
: s.status?.since ? ago(s.status.since) : ago(s.activity);
|
2026-10-07 23:34:59 -04:00
|
|
|
|
const model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : '';
|
2026-10-07 03:03:09 -04:00
|
|
|
|
const meta = s.running
|
2026-10-07 23:34:59 -04:00
|
|
|
|
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
|
2026-10-07 19:26:06 -04:00
|
|
|
|
.filter(Boolean).join(' · ')
|
2026-10-07 03:03:09 -04:00
|
|
|
|
: 'dormant · resumes Claude';
|
2026-10-07 23:34:59 -04:00
|
|
|
|
const pct = s.context?.used_pct;
|
|
|
|
|
|
const title = [
|
|
|
|
|
|
s.dir,
|
|
|
|
|
|
status.key === 'waiting' && s.status?.message,
|
2026-10-11 22:07:39 -04:00
|
|
|
|
// 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)}` : ''}`,
|
2026-10-07 23:34:59 -04:00
|
|
|
|
].filter(Boolean).join('\n');
|
2026-10-07 03:03:09 -04:00
|
|
|
|
const iconButton = (label, glyph, onclick, extra = '') =>
|
|
|
|
|
|
el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph);
|
2026-10-07 19:26:06 -04:00
|
|
|
|
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}` }),
|
2026-10-10 20:37:46 -04:00
|
|
|
|
el('span', { class: 'dd-session-name' }, s.name,
|
2026-10-11 23:28:11 -04:00
|
|
|
|
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,
|
2026-10-10 21:45:26 -04:00
|
|
|
|
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),
|
2026-10-07 23:34:59 -04:00
|
|
|
|
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),
|
2026-10-07 03:03:09 -04:00
|
|
|
|
el('span', { class: 'dd-session-actions' },
|
2026-10-11 23:28:11 -04:00
|
|
|
|
// 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,
|
2026-10-07 03:03:09 -04:00
|
|
|
|
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')));
|
2026-10-06 23:11:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) =>
|
2026-10-07 03:03:09 -04:00
|
|
|
|
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
|
|
|
|
|
|
const items = ordered.map((s) => this.renderSession(s));
|
2026-10-06 23:11:07 -04:00
|
|
|
|
for (const r of recentsOnly) {
|
2026-10-07 03:03:09 -04:00
|
|
|
|
items.push(el('li', { class: 'dd-session-item is-recent' },
|
|
|
|
|
|
el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.hooks.onOpen(r.name) },
|
2026-10-06 23:11:07 -04:00
|
|
|
|
el('span', { class: 'lt-dot lt-dot-idle' }),
|
|
|
|
|
|
el('span', { class: 'dd-session-name' }, r.name),
|
2026-10-07 03:03:09 -04:00
|
|
|
|
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(); },
|
|
|
|
|
|
}, '×'))));
|
2026-10-06 23:11:07 -04:00
|
|
|
|
}
|
2026-10-07 03:03:09 -04:00
|
|
|
|
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
|
2026-10-22 20:53:34 -04:00
|
|
|
|
const scroll = this.list.scrollTop;
|
2026-10-07 03:03:09 -04:00
|
|
|
|
this.list.replaceChildren(...items);
|
2026-10-22 20:53:34 -04:00
|
|
|
|
// 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' });
|
|
|
|
|
|
}
|
2026-10-06 23:11:07 -04:00
|
|
|
|
|
2026-10-08 00:32:23 -04:00
|
|
|
|
this.projectOptions(this.select);
|
2026-10-06 23:11:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|