feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
ttyd serves a single-file xterm.js UI styled with the LotusGuild TDS (web_template base.css). Sessions live in a dedicated tmux server unit (devdesk-tmux.service), so restarting or upgrading the web unit never stops Claude. Each named session maps to a project directory. A Claude SessionStart hook records the conversation ID, so after a reboot the session resumes that exact conversation. - bin/devdesk: attach / new / ls / end / pick, with an fzf picker - ttyd requires a credential that NPM injects; origin check enabled - debian/: dh 13 with --no-stop-on-upgrade for the tmux unit, bundled sha256-verified ttyd 1.7.7, lintian overrides, man page - CI: lint, test, build, smoke-test, artifact; v* tags publish to the Gitea apt registry and create a release Refs: tinker ticket 887531976 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// Sessions panel: server state (via `devdesk attach --list`) plus recently used
|
||||
// sessions remembered in this browser.
|
||||
|
||||
import { fetchSessionList } from './connection.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 [];
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberSession(name) {
|
||||
if (!name) return;
|
||||
try {
|
||||
const recents = readRecents().filter((r) => r.name !== name);
|
||||
recents.unshift({ name, at: Date.now() });
|
||||
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
|
||||
} catch {
|
||||
// Storage unavailable (private mode): recents are a convenience only.
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'class') node.className = value;
|
||||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
||||
else node.setAttribute(key, value);
|
||||
}
|
||||
node.append(...children.filter((c) => c !== null && c !== undefined));
|
||||
return node;
|
||||
}
|
||||
|
||||
export class SessionsPanel {
|
||||
constructor({ list, select, openButton, refreshButton, pickerButton, status, onOpen, onPicker }) {
|
||||
Object.assign(this, { list, select, status, onOpen });
|
||||
this.current = null;
|
||||
this.data = null;
|
||||
refreshButton.addEventListener('click', () => this.refresh());
|
||||
pickerButton.addEventListener('click', () => onPicker());
|
||||
openButton.addEventListener('click', () => {
|
||||
if (select.value) onOpen(select.value);
|
||||
});
|
||||
}
|
||||
|
||||
setCurrent(name) {
|
||||
this.current = name;
|
||||
if (this.data) this.render();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.status.textContent = 'refreshing…';
|
||||
try {
|
||||
this.data = await fetchSessionList();
|
||||
this.status.textContent = '';
|
||||
} catch (err) {
|
||||
this.status.textContent = `list unavailable: ${err.message}`;
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
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);
|
||||
this.list.replaceChildren();
|
||||
|
||||
const ordered = [...sessions].sort((a, b) =>
|
||||
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)));
|
||||
for (const s of ordered) {
|
||||
const isCurrent = s.name === this.current;
|
||||
const meta = s.running
|
||||
? `${s.attached} attached · ${ago(s.activity)}`
|
||||
: 'dormant · resumes Claude';
|
||||
this.list.append(el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}` },
|
||||
el('button', {
|
||||
class: 'dd-session-open', type: 'button', title: s.dir || '',
|
||||
onclick: () => this.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('a', {
|
||||
class: 'dd-session-tab', href: `?arg=${encodeURIComponent(s.name)}`,
|
||||
target: '_blank', rel: 'noopener', title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`,
|
||||
}, '↗')));
|
||||
}
|
||||
for (const r of recentsOnly) {
|
||||
this.list.append(el('li', { class: 'dd-session-item is-recent' },
|
||||
el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.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'))));
|
||||
}
|
||||
if (!this.list.children.length) {
|
||||
this.list.append(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
|
||||
}
|
||||
|
||||
const projects = (this.data?.projects ?? []).filter((p) => !known.has(p.name));
|
||||
this.select.replaceChildren(el('option', { value: '' }, projects.length ? 'New session in…' : 'No other projects'));
|
||||
for (const p of projects) this.select.append(el('option', { value: p.name, title: p.dir }, p.name));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user