feat: v0.6.0 light mode, command palette, dialogs, toasts
- Light mode with the house ☀/◐ toggle and lt_theme preference; light terminal palette; Claude runs with dark-ansi so its colours follow the palette; tmux and the statusLine use ANSI colours. - Command palette (⌕, Ctrl+Shift+P) with session, terminal, view, link and help commands. - .lt-modal dialogs for new/rename/end and a keyboard shortcuts sheet. - .lt-toast results for actions, uploads, copy and CI. - ttyd dropped the reply of fast one-shot commands; replies now wait for the browser to hang up, and the client hangs up as soon as it has one. - Sessions panel re-fetches after an action instead of reusing a stale poll. Closes #31 Closes #32 Closes #33 Closes #34 Closes #35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+105
-40
@@ -4,6 +4,10 @@
|
||||
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;
|
||||
@@ -51,17 +55,6 @@ export function ago(epochSeconds) {
|
||||
return `${Math.floor(s / 86400)}d`;
|
||||
}
|
||||
|
||||
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 === true ? '' : value);
|
||||
}
|
||||
node.append(...children.filter((c) => c !== null && c !== undefined));
|
||||
return node;
|
||||
}
|
||||
|
||||
// What Claude is doing in a session, from the devdesk-state-hook snapshot.
|
||||
// Returns { key, label, dot } for display; key is one of
|
||||
@@ -114,6 +107,12 @@ export class SessionsPanel {
|
||||
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 {
|
||||
@@ -135,14 +134,13 @@ export class SessionsPanel {
|
||||
this.hooks.onData?.(this.data, { fetched: false });
|
||||
}
|
||||
|
||||
async act(label, action, ...args) {
|
||||
this.setStatus(`${label}…`);
|
||||
// Side-channel action; failures become an error toast (devdesk#34).
|
||||
async act(action, ...args) {
|
||||
try {
|
||||
await sessionAction(action, ...args);
|
||||
this.setStatus('');
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.setStatus(err.message, true);
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -151,43 +149,116 @@ export class SessionsPanel {
|
||||
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);
|
||||
toast.warning('Type a session name or pick a project.');
|
||||
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;
|
||||
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 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)) {
|
||||
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);
|
||||
await this.refresh();
|
||||
toast.success(`Renamed ${name} → ${next}`);
|
||||
await this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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(session.running ? 'ending' : 'forgetting', 'end', session.name)) {
|
||||
if (await this.act('end', session.name)) {
|
||||
forgetRecent(session.name);
|
||||
this.hooks.onEnded(session.name);
|
||||
await this.refresh();
|
||||
toast.success(session.running ? `Ended ${session.name}` : `Forgot ${session.name}`);
|
||||
await this.reload();
|
||||
} else if (session.name === this.current) {
|
||||
this.hooks.onEndAborted?.();
|
||||
}
|
||||
@@ -250,12 +321,6 @@ export class SessionsPanel {
|
||||
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
|
||||
this.list.replaceChildren(...items);
|
||||
|
||||
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;
|
||||
this.projectOptions(this.select);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user