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>
This commit is contained in:
+21
-11
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit,
|
||||
encodeInput, encodeResize, parseListOutput, tokenUrl, wsUrl, FlowControl,
|
||||
encodeInput, encodeResize, parseListOutput, stripAnsi, tokenUrl, wsUrl, FlowControl,
|
||||
} from './protocol.js';
|
||||
|
||||
export class AuthExpiredError extends Error {}
|
||||
@@ -94,16 +94,17 @@ export class TtydConnection {
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the server for sessions + projects: a short-lived `devdesk attach --list`.
|
||||
export async function fetchSessionList(timeoutMs = 8000) {
|
||||
// Run a short-lived `devdesk attach <args…>` and return its output as text.
|
||||
// ttyd is the only server-side endpoint, so session actions travel this way.
|
||||
export async function runCommand(args, timeoutMs = 8000) {
|
||||
const token = await fetchToken();
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']);
|
||||
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
const chunks = [];
|
||||
const timer = setTimeout(() => {
|
||||
socket.close();
|
||||
reject(new Error('session list timed out'));
|
||||
reject(new Error('devdesk did not answer'));
|
||||
}, timeoutMs);
|
||||
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
||||
socket.onmessage = (event) => {
|
||||
@@ -113,16 +114,25 @@ export async function fetchSessionList(timeoutMs = 8000) {
|
||||
socket.onerror = () => {};
|
||||
socket.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const text = new TextDecoder().decode(concat(chunks));
|
||||
resolve(parseListOutput(text));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(new TextDecoder().decode(concat(chunks)));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sessions, projects and per-session Claude context (`devdesk attach --list`).
|
||||
export async function fetchSessionList() {
|
||||
return parseListOutput(await runCommand([LIST_ARG]));
|
||||
}
|
||||
|
||||
// `devdesk attach --new|--rename|--end …`: resolves on {"ok":true}, otherwise
|
||||
// rejects with devdesk's own error message.
|
||||
export async function sessionAction(action, ...args) {
|
||||
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
|
||||
if (/"ok"\s*:\s*true/.test(output)) return;
|
||||
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
|
||||
throw new Error(message.replace(/^devdesk:\s*/, ''));
|
||||
}
|
||||
|
||||
function concat(chunks) {
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Claude Context panel: the current session's model, context-window use, cost
|
||||
// and edits, from the snapshot devdesk-statusline writes on every Claude update.
|
||||
|
||||
import { ago } from './sessions.js';
|
||||
|
||||
function compact(tokens) {
|
||||
if (tokens == null) return '—';
|
||||
if (tokens >= 1e6) return `${+(tokens / 1e6).toFixed(tokens % 1e6 ? 1 : 0)}M`;
|
||||
if (tokens >= 1e3) return `${Math.round(tokens / 1e3)}k`;
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
export function renderContext(root, session, context) {
|
||||
const set = (field, text) => {
|
||||
const node = root.querySelector(`[data-field="${field}"]`);
|
||||
if (node) node.textContent = text;
|
||||
};
|
||||
const bar = root.querySelector('.lt-progress');
|
||||
const fill = root.querySelector('.lt-progress-bar');
|
||||
const note = root.querySelector('.dd-context-note');
|
||||
|
||||
if (!context) {
|
||||
root.dataset.state = 'empty';
|
||||
fill.style.width = '0%';
|
||||
bar.setAttribute('aria-valuenow', '0');
|
||||
for (const f of ['used', 'model', 'cost', 'lines', 'updated']) set(f, '—');
|
||||
note.textContent = session
|
||||
? 'No data yet: it appears once Claude has redrawn its status line in this session.'
|
||||
: 'Open a session to see its context.';
|
||||
return;
|
||||
}
|
||||
|
||||
const pct = context.used_pct;
|
||||
root.dataset.state = 'live';
|
||||
fill.style.width = `${Math.min(100, pct ?? 0)}%`;
|
||||
bar.setAttribute('aria-valuenow', String(Math.round(pct ?? 0)));
|
||||
bar.classList.remove('lt-progress--cyan', 'lt-progress--red');
|
||||
if (pct == null || pct < 70) bar.classList.add('lt-progress--cyan');
|
||||
else if (pct >= 90) bar.classList.add('lt-progress--red');
|
||||
|
||||
set('used', pct == null
|
||||
? `— / ${compact(context.window)}`
|
||||
: `${compact(context.used_tokens)} / ${compact(context.window)} (${pct.toFixed(pct < 10 ? 1 : 0)}%)`);
|
||||
set('model', [context.model, context.effort].filter(Boolean).join(' · ') || '—');
|
||||
set('cost', context.cost_usd == null ? '—' : `$${context.cost_usd.toFixed(2)}`);
|
||||
set('lines', `+${context.lines_added ?? 0} −${context.lines_removed ?? 0}`);
|
||||
set('updated', context.updated ? `${ago(context.updated)} ago`.replace('now ago', 'just now') : '—');
|
||||
note.textContent = pct != null && pct >= 80
|
||||
? 'Context is filling up; consider /compact or a new conversation.'
|
||||
: '';
|
||||
}
|
||||
+47
-5
@@ -3,6 +3,7 @@ import { createTerminal, loadFonts } from './term.js';
|
||||
import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
|
||||
import { parseTitle, sessionFromSearch } from './protocol.js';
|
||||
import { SessionsPanel, rememberSession } from './sessions.js';
|
||||
import { renderContext } from './context.js';
|
||||
import { bindAskClaude } from './widgets.js';
|
||||
import * as notify from './notify.js';
|
||||
|
||||
@@ -47,6 +48,7 @@ const state = {
|
||||
attempts: 0,
|
||||
retryTimer: null,
|
||||
openedAt: 0,
|
||||
endingCurrent: false,
|
||||
lastActivity: Date.now(),
|
||||
};
|
||||
|
||||
@@ -88,6 +90,18 @@ function hideOverlay() {
|
||||
ui.overlay.hidden = true;
|
||||
}
|
||||
|
||||
function showContext() {
|
||||
const data = sessionsPanel?.data;
|
||||
const current = data?.sessions?.find((s) => s.name === state.session);
|
||||
renderContext($('widget-context'), state.session, current?.context ?? null);
|
||||
}
|
||||
|
||||
let refreshTimer = 0;
|
||||
function refreshSoon(delay = 1500) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => sessionsPanel?.refresh(), delay);
|
||||
}
|
||||
|
||||
function updateUrl(session) {
|
||||
const url = new URL(window.location.href);
|
||||
if (session) url.searchParams.set('arg', session);
|
||||
@@ -103,6 +117,7 @@ function connect(session) {
|
||||
state.session = session;
|
||||
updateUrl(session);
|
||||
sessionsPanel?.setCurrent(session);
|
||||
showContext();
|
||||
ui.sessionChip.textContent = session || 'picker';
|
||||
hideOverlay();
|
||||
setWsState('connecting', 'connecting');
|
||||
@@ -132,6 +147,12 @@ const pickerAction = { label: 'Sessions…', run: () => connect(null) };
|
||||
|
||||
function onClosed(code) {
|
||||
setWsState('disconnected', 'offline');
|
||||
if (state.endingCurrent) {
|
||||
// We just ended the session we were attached to: go back to the picker.
|
||||
state.endingCurrent = false;
|
||||
connect(null);
|
||||
return;
|
||||
}
|
||||
// ttyd reports a non-zero exit of `devdesk attach` with an invalid close code,
|
||||
// which browsers surface as 1006 — same as a dropped network. A close within a
|
||||
// few seconds of opening means the session could not start (the reason is
|
||||
@@ -229,6 +250,7 @@ function applyTitle() {
|
||||
updateUrl(parsed.session);
|
||||
rememberSession(parsed.session);
|
||||
sessionsPanel.setCurrent(parsed.session);
|
||||
showContext();
|
||||
}
|
||||
ui.sessionChip.textContent = parsed.session;
|
||||
ui.paneTitle.textContent = parsed.pane;
|
||||
@@ -311,7 +333,7 @@ function wireChrome() {
|
||||
});
|
||||
setInterval(() => {
|
||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh();
|
||||
}, 30 * 1000);
|
||||
}, 15 * 1000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- boot
|
||||
@@ -332,7 +354,10 @@ async function boot() {
|
||||
pendingTitle = title;
|
||||
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
|
||||
});
|
||||
term.onBell(() => notify.ring(state.session, ui.paneTitle.textContent));
|
||||
term.onBell(() => {
|
||||
notify.ring(state.session, ui.paneTitle.textContent);
|
||||
refreshSoon(); // Claude finished a turn: context numbers just changed
|
||||
});
|
||||
|
||||
let fitFrame = 0;
|
||||
new ResizeObserver(() => {
|
||||
@@ -347,13 +372,30 @@ async function boot() {
|
||||
|
||||
sessionsPanel = new SessionsPanel({
|
||||
list: $('sessions-list'),
|
||||
form: $('new-session-form'),
|
||||
nameInput: $('new-session-name'),
|
||||
select: $('project-select'),
|
||||
openButton: $('project-open'),
|
||||
refreshButton: $('sessions-refresh'),
|
||||
pickerButton: $('open-picker'),
|
||||
status: $('sessions-status'),
|
||||
onOpen: (name) => connect(name),
|
||||
onPicker: () => connect(null),
|
||||
hooks: {
|
||||
onOpen: (name) => connect(name),
|
||||
onPicker: () => connect(null),
|
||||
onEnding: () => { state.endingCurrent = true; },
|
||||
onEnded: (name) => {
|
||||
if (name !== state.session) state.endingCurrent = false;
|
||||
},
|
||||
onEndAborted: () => { state.endingCurrent = false; },
|
||||
onRenamed: (from, to) => {
|
||||
if (from !== state.session) return;
|
||||
state.session = to;
|
||||
updateUrl(to);
|
||||
rememberSession(to);
|
||||
ui.sessionChip.textContent = to;
|
||||
sessionsPanel.setCurrent(to);
|
||||
},
|
||||
onData: () => showContext(),
|
||||
},
|
||||
});
|
||||
bindAskClaude($('sidebar'), (text) => {
|
||||
state.conn?.sendInput(text);
|
||||
|
||||
+16
-4
@@ -53,12 +53,25 @@ export function tokenUrl(location) {
|
||||
return `${location.protocol}//${location.host}${basePath(location)}token`;
|
||||
}
|
||||
|
||||
export function wsUrl(location, arg) {
|
||||
// ttyd --url-arg appends each ?arg= to the command line: `devdesk attach <args…>`.
|
||||
export function wsUrl(location, args) {
|
||||
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const query = arg ? `?arg=${encodeURIComponent(arg)}` : '';
|
||||
const list = Array.isArray(args) ? args : args ? [args] : [];
|
||||
const query = list.length ? `?${list.map((a) => `arg=${encodeURIComponent(a)}`).join('&')}` : '';
|
||||
return `${scheme}//${location.host}${basePath(location)}ws${query}`;
|
||||
}
|
||||
|
||||
// Session names are [A-Za-z0-9_-]; turn free text ("PBS upgrade!") into one ("PBS-upgrade").
|
||||
export function slugify(text) {
|
||||
return String(text || '').trim().replace(/[^A-Za-z0-9_-]+/g, '-')
|
||||
.replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64);
|
||||
}
|
||||
|
||||
export function stripAnsi(text) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '');
|
||||
}
|
||||
|
||||
export function sessionFromSearch(search) {
|
||||
const value = new URLSearchParams(search).get('arg');
|
||||
return value && SESSION_RE.test(value) ? value : null;
|
||||
@@ -74,8 +87,7 @@ export function parseTitle(title) {
|
||||
// Output of `devdesk attach --list` arrives through a pty: CRLF line endings and
|
||||
// possibly split across frames. Pull the JSON object out of the joined text.
|
||||
export function parseListOutput(raw) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const text = raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); // tools may colorize on a tty
|
||||
const text = stripAnsi(raw); // tools may colorize on a tty
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start < 0 || end < start) throw new Error('no session list in output');
|
||||
|
||||
+139
-47
@@ -1,7 +1,8 @@
|
||||
// Sessions panel: server state (via `devdesk attach --list`) plus recently used
|
||||
// sessions remembered in this browser.
|
||||
// Sessions panel: server state (via `devdesk attach --list`), recently used
|
||||
// sessions remembered in this browser, and session actions (new/rename/end).
|
||||
|
||||
import { fetchSessionList } from './connection.js';
|
||||
import { fetchSessionList, sessionAction } from './connection.js';
|
||||
import { slugify } from './protocol.js';
|
||||
|
||||
const RECENTS_KEY = 'devdesk.recents';
|
||||
const MAX_RECENTS = 12;
|
||||
@@ -15,18 +16,27 @@ function readRecents() {
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberSession(name) {
|
||||
if (!name) return;
|
||||
function writeRecents(recents) {
|
||||
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) {
|
||||
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';
|
||||
@@ -35,26 +45,30 @@ function ago(epochSeconds) {
|
||||
return `${Math.floor(s / 86400)}d`;
|
||||
}
|
||||
|
||||
function el(tag, attrs = {}, ...children) {
|
||||
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);
|
||||
else node.setAttribute(key, value === true ? '' : 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 });
|
||||
// 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', () => onPicker());
|
||||
openButton.addEventListener('click', () => {
|
||||
if (select.value) onOpen(select.value);
|
||||
pickerButton.addEventListener('click', () => hooks.onPicker());
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
this.createFromForm();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,56 +77,134 @@ export class SessionsPanel {
|
||||
if (this.data) this.render();
|
||||
}
|
||||
|
||||
setStatus(text, isError = false) {
|
||||
this.status.textContent = text;
|
||||
this.status.classList.toggle('is-error', isError);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.status.textContent = 'refreshing…';
|
||||
if (this.busy) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
this.data = await fetchSessionList();
|
||||
this.status.textContent = '';
|
||||
if (!this.status.classList.contains('is-error')) this.setStatus('');
|
||||
} catch (err) {
|
||||
this.status.textContent = `list unavailable: ${err.message}`;
|
||||
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);
|
||||
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`,
|
||||
}, '↗')));
|
||||
}
|
||||
(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) {
|
||||
this.list.append(el('li', { class: 'dd-session-item is-recent' },
|
||||
el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.onOpen(r.name) },
|
||||
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'))));
|
||||
}
|
||||
if (!this.list.children.length) {
|
||||
this.list.append(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
|
||||
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 ?? []).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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user