feat: v0.12.0 Settings window, per-session Remote Control, delete conversations
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

- 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
This commit is contained in:
2026-11-03 21:23:29 -05:00
co-authored by Claude Opus 5.5
parent 65114165e7
commit 20134c1e58
14 changed files with 454 additions and 50 deletions
+1 -10
View File
@@ -53,16 +53,7 @@
<div class="dd-more">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
<div class="dd-menu-row">
<span>Text size</span>
<span class="dd-menu-inline">
<button type="button" class="dd-icon-btn" id="font-down" aria-label="Smaller text">A−</button>
<span id="font-size">14</span>
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
</span>
</div>
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
<button type="button" class="dd-menu-item" id="settings-menu" role="menuitem">Settings…</button>
<div class="dd-menu-row dd-usage-row" id="usage-row" hidden><span>Plan usage</span><span class="dd-usage" id="usage-menu"></span></div>
<button type="button" class="dd-menu-item" id="history-menu" role="menuitem">Search past conversations</button>
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</button>
+23 -3
View File
@@ -6,7 +6,7 @@ import { runCommand, sessionAction } from './connection.js';
import { parseListOutput, slugify } from './protocol.js';
import { el } from './dom.js';
import { ago } from './sessions.js';
import { openDialog, toast } from './ui.js';
import { confirmDialog, openDialog, toast } from './ui.js';
const ROLE = { you: 'You', claude: 'Claude', command: 'Ran', output: 'Output' };
@@ -82,9 +82,28 @@ export function openHistory(hooks) {
el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm',
onclick: () => navigator.clipboard?.writeText(`claude --resume ${r.id}`).then(() => toast.success('Copied the resume command')),
}, 'Copy command'));
}, 'Copy command'),
live ? null : el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-danger-text', title: 'Delete this conversation from this machine for good',
onclick: async () => {
const ok = await confirmDialog({
title: 'Delete this conversation?',
text: `“${r.title.slice(0, 80)}” (${day(r.started)}) is removed from this machine for good: its transcript and subagent files in ~/.claude/projects. It can't be resumed or found again. If its session had Remote Control on, a copy is also at claude.ai/code: delete it there too.`,
confirmLabel: 'Delete for good',
danger: true,
});
if (!ok) return;
try {
await sessionAction('delete-convo', r.id);
item.remove();
toast.success('Conversation deleted from this machine', 8000, [{ label: 'claude.ai/code', run: () => window.open('https://claude.ai/code', '_blank', 'noopener') }]);
} catch (error) {
toast.error(error.message);
}
},
}, 'Delete…'));
fill();
return el('div', { class: 'dd-history-item' },
const item = el('div', { class: 'dd-history-item' },
el('div', { class: 'dd-history-title' }, r.title),
el('div', { class: 'dd-history-meta' },
[r.cwd, day(r.started), `updated ${ago(r.updated)}${ago(r.updated) === 'now' ? '' : ' ago'}`, `${r.hits} match${r.hits === 1 ? '' : 'es'}`, live ? `open in ${live}` : '']
@@ -92,6 +111,7 @@ export function openHistory(hooks) {
...r.matches.map((m) => el('div', { class: `dd-history-snippet is-${m.role}` },
el('span', { class: 'dd-history-role' }, ROLE[m.role] ?? m.role), el('span', {}, ...highlight(m.snippet, data.query)))),
actions);
return item;
}));
};
+39 -30
View File
@@ -1,6 +1,6 @@
/* global __DEVDESK_VERSION__ */
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import { getTheme, initTheme, onThemeChange, setTheme, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, lastReply, replyTo, sessionAction, uploadFile,
AuthExpiredError, UnauthorizedError,
@@ -16,6 +16,7 @@ import { Inbox } from './inbox.js';
import { FilesPane } from './files.js';
import { registerPathLinks } from './links.js';
import { openHistory } from './history.js';
import { applyUiScale, openSettings } from './settings.js';
import { el } from './dom.js';
import * as notify from './notify.js';
@@ -55,7 +56,6 @@ const ui = {
overlayActions: $('overlay-actions'),
moreToggle: $('more-toggle'),
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
backdrop: $('backdrop'),
};
@@ -610,10 +610,41 @@ function setFontSize(size) {
term.options.fontSize = clamped;
store.set('fontSize', clamped);
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
$('font-size').textContent = String(clamped);
fit.fit();
}
// Settings (devdesk#61): appearance, notifications, Remote Control per session.
function showSettings() {
closeMenus();
openSettings({
getTheme,
setTheme,
fontSize: () => term.options.fontSize,
setFontSize,
uiScale: () => Number(store.get('uiScale', 1)) || 1,
setUiScale: (scale) => {
store.set('uiScale', scale);
applyUiScale(scale);
requestAnimationFrame(() => fit.fit());
},
notifications: () => {
const current = notify.notificationsState();
return {
label: {
unsupported: 'Not supported', denied: 'Blocked by the browser', default: 'Turn on',
granted: notify.isMuted() ? 'Muted: unmute' : 'On: mute',
}[current],
disabled: current === 'unsupported' || current === 'denied',
run: toggleNotifications,
};
},
sessions: () => sessionsPanel?.data,
current: () => state.session,
refresh: () => sessionsPanel.reload(),
openHistory: showHistory,
});
}
async function toggleNotifications() {
const current = notify.notificationsState();
if (current === 'default') {
@@ -850,12 +881,13 @@ function buildCommands() {
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
add({ id: 'settings', group: 'View', icon: '⚙', label: 'Settings…', keywords: 'preferences text size font theme remote control privacy notifications', run: showSettings });
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
if (notifyState !== 'unsupported') {
add({
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
run: () => toggleNotifications().then(renderNotifyButton),
run: () => toggleNotifications(),
});
}
@@ -968,17 +1000,6 @@ async function toggleFullscreen() {
navigator.keyboard?.lock?.().catch(() => {});
}
function renderNotifyButton() {
const current = notify.notificationsState();
const labels = {
unsupported: 'Desktop notifications: unsupported',
denied: 'Desktop notifications: blocked by the browser',
default: 'Enable desktop notifications',
granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)',
};
ui.notifyButton.textContent = labels[current];
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
}
function setSidebar(open) {
if (open && files?.isOpen) files.close();
@@ -1028,12 +1049,7 @@ function wireKeybar() {
}
function wireChrome() {
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
ui.notifyButton.addEventListener('click', async () => {
await toggleNotifications();
renderNotifyButton();
});
$('settings-menu').addEventListener('click', showSettings);
$('fullscreen-toggle').addEventListener('click', () => {
closeMenus();
toggleFullscreen();
@@ -1095,14 +1111,7 @@ function wireChrome() {
else if (!dialogOpen()) palette.open();
}
}, true);
wireMenu(ui.moreToggle, ui.moreMenu, () => {
renderNotifyButton();
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
});
$('theme-menu').addEventListener('click', () => {
closeMenus();
toggleTheme();
});
wireMenu(ui.moreToggle, ui.moreMenu, () => {});
inbox = new Inbox({
button: $('inbox-toggle'),
badge: $('inbox-badge'),
@@ -1233,7 +1242,7 @@ async function boot() {
initTheme();
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
$('font-size').textContent = String(fontSize);
applyUiScale(Number(store.get('uiScale', 1)) || 1);
await loadFonts(fontSize);
({ term, fit } = createTerminal($('terminal'), {
+2
View File
@@ -321,6 +321,8 @@ export class SessionsPanel {
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)
+134
View File
@@ -0,0 +1,134 @@
// Settings (devdesk#61): the things you set once and leave, in one window
// instead of the ⋯ menu. Appearance (theme, terminal text size, interface size)
// and notifications are per browser; Remote Control is per session, with a
// default for new sessions, kept on the server.
import { el } from './dom.js';
import { openDialog, toast } from './ui.js';
import { sessionAction } from './connection.js';
const SCALES = [['Small', 0.9], ['Normal', 1], ['Large', 1.12], ['Larger', 1.25]];
// Interface size: the page is sized in rem off the root font size (14px).
export function applyUiScale(scale) {
document.documentElement.style.fontSize = scale === 1 ? '' : `${Math.round(14 * scale * 10) / 10}px`;
}
function segmented(label, options, current, onPick) {
const buttons = options.map(([text, value]) => el('button', {
type: 'button', class: 'dd-seg-btn', 'aria-pressed': String(value === current),
onclick: (event) => {
for (const b of buttons) b.setAttribute('aria-pressed', 'false');
event.currentTarget.setAttribute('aria-pressed', 'true');
onPick(value);
},
}, text));
return el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, label), el('span', { class: 'dd-seg', role: 'group', 'aria-label': label }, ...buttons));
}
function toggle(label, checked, onChange, hint) {
const input = el('input', { type: 'checkbox', role: 'switch', checked: checked ? true : null });
input.addEventListener('change', () => onChange(input.checked, input));
return el('label', { class: 'dd-setting dd-switch-row' },
el('span', { class: 'dd-setting-label' }, label, hint ? el('span', { class: 'dd-setting-hint' }, hint) : null),
el('span', { class: 'dd-switch' }, input, el('span', { class: 'dd-switch-track', 'aria-hidden': 'true' })));
}
// hooks: getTheme(), setTheme(t), fontSize(), setFontSize(n), uiScale(), setUiScale(s),
// notifications() -> { label, disabled, run }, sessions() -> list data, current(),
// refresh(), openHistory()
export async function openSettings(hooks) {
const data = hooks.sessions();
const current = hooks.current();
const size = el('span', { class: 'dd-setting-value' }, String(hooks.fontSize()));
const setSize = (n) => {
hooks.setFontSize(n);
size.textContent = String(hooks.fontSize());
};
const notif = hooks.notifications();
const notifyButton = el('button', { type: 'button', class: 'lt-btn lt-btn-sm', disabled: notif.disabled ? true : null }, notif.label);
notifyButton.addEventListener('click', async () => {
await hooks.notifications().run();
const next = hooks.notifications();
notifyButton.textContent = next.label;
notifyButton.disabled = next.disabled;
});
// Remote Control, per session.
const running = (data?.sessions ?? []).filter((s) => s.running).sort((a, b) => (a.name === current ? -1 : b.name === current ? 1 : a.name.localeCompare(b.name)));
const rcRows = running.map((s) => toggle(s.name, s.remote_control, async (on, input) => {
input.disabled = true;
try {
const result = await sessionAction('remote-control', s.name, on ? 'on' : 'off');
const what = `Remote Control ${on ? 'on' : 'off'}`;
if (result?.applied === 'now') toast.success(`${s.name}: restarting Claude with ${what} (same conversation)`, 6000);
else if (result?.applied === 'next-start') toast.info(`${s.name} is busy: ${what} when Claude next starts`, 7000);
else toast.success(`${s.name}: ${what}`);
hooks.refresh();
} catch (error) {
input.checked = !on;
toast.error(`${s.name}: ${error.message}`);
} finally {
input.disabled = false;
}
}, s.rc_active ? 'syncing to claude.ai now' : s.remote_control ? 'on at the next Claude start' : ''));
const defaultRow = toggle('New sessions', Boolean(data?.remote_control_default), async (on, input) => {
input.disabled = true;
try {
await sessionAction('settings', 'remote-control-default', on ? 'on' : 'off');
toast.success(`New sessions: Remote Control ${on ? 'on' : 'off'}`);
hooks.refresh();
} catch (error) {
input.checked = !on;
toast.error(error.message);
} finally {
input.disabled = false;
}
}, 'sessions you create from now on');
const link = (href, text) => el('a', { href, target: '_blank', rel: 'noopener' }, text);
const body = el('div', { class: 'dd-settings' },
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Appearance'),
segmented('Theme', [['Dark', 'dark'], ['Light', 'light']], hooks.getTheme(), (t) => hooks.setTheme(t)),
el('div', { class: 'dd-setting' },
el('span', { class: 'dd-setting-label' }, 'Terminal text size'),
el('span', { class: 'dd-stepper' },
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Smaller terminal text', onclick: () => setSize(hooks.fontSize() - 1) }, 'A−'),
size,
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Larger terminal text', onclick: () => setSize(hooks.fontSize() + 1) }, 'A+'),
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => setSize(14) }, 'Reset'))),
segmented('Interface size', SCALES, hooks.uiScale(), (s) => hooks.setUiScale(s)),
el('p', { class: 'dd-setting-note' }, 'Appearance is remembered in this browser.')),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Notifications'),
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Desktop notifications', el('span', { class: 'dd-setting-hint' }, 'when another session needs you')), notifyButton)),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Remote Control & privacy'),
el('p', { class: 'dd-setting-note' },
'With Remote Control on, a session also appears in the Claude app and at claude.ai/code, and Anthropic stores its transcript to keep your devices in sync (30 days, or 5 years if "Help improve Claude" is on in ',
link('https://claude.ai/settings/data-privacy-controls', 'your privacy settings'), '). Turning it off stops syncing from then on; what was already synced stays until you delete it at ',
link('https://claude.ai/code', 'claude.ai/code'), '. Switching a running session restarts its Claude on the same conversation.'),
defaultRow,
running.length ? el('div', { class: 'dd-setting-sub' }, 'Running sessions') : null,
...rcRows),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Conversations'),
el('p', { class: 'dd-setting-note' }, 'Every conversation is also kept on this machine in ~/.claude/projects (plaintext, 30 days). Delete one for good from Search past conversations.'),
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Find or delete a conversation'),
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', 'data-close': 'history' }, 'Search past conversations…'))));
const choice = await openDialog({
title: 'Settings',
className: 'dd-settings-dialog',
body,
onOpen: () => {
body.querySelector('[data-close="history"]').addEventListener('click', () => {
body.closest('.lt-modal-overlay, .lt-modal')?.querySelector('[aria-label="Close"], .lt-modal-close')?.click();
setTimeout(() => hooks.openHistory(), 50);
});
return body.querySelector('button, input');
},
actions: [{ label: 'Done', primary: true }],
});
return choice;
}
+29
View File
@@ -1144,3 +1144,32 @@ body.dd-touch .dd-footer-usage { display: none; }
.dd-search-ln { flex: none; min-width: 2.5em; color: var(--text-muted); text-align: right; }
.dd-search-text { overflow: hidden; text-overflow: ellipsis; }
.dd-search-text mark { background: var(--accent-amber-dim); color: var(--accent-amber); outline: 1px solid color-mix(in srgb, var(--accent-amber) 50%, transparent); }
/* Settings (devdesk#61) */
.dd-settings-dialog .lt-modal { width: min(620px, calc(100vw - 24px)); max-height: calc(100vh - 40px); }
.dd-settings-dialog .dd-dialog-body { max-height: calc(100vh - 170px); overflow-y: auto; }
.dd-settings { display: flex; flex-direction: column; gap: 18px; }
.dd-settings-group h3 { margin: 0 0 8px; color: var(--accent-orange); font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.12em; text-transform: uppercase; }
.dd-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 34px; padding: 4px 0; border-bottom: 1px solid var(--border-dim); }
.dd-setting-label { display: flex; flex-direction: column; color: var(--text-primary); font-size: 0.8rem; }
.dd-setting-hint { color: var(--text-muted); font-size: 0.68rem; }
.dd-setting-value { min-width: 2ch; text-align: center; font-family: var(--font-mono); }
.dd-setting-note { margin: 4px 0 8px; color: var(--text-secondary); font-size: 0.72rem; line-height: 1.5; }
.dd-setting-note a { color: var(--accent-cyan); }
.dd-setting-sub { margin-top: 10px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.64rem; letter-spacing: 0.08em; text-transform: uppercase; }
.dd-stepper { display: inline-flex; align-items: center; gap: 6px; }
.dd-seg { display: inline-flex; border: 1px solid var(--border-color); }
.dd-seg-btn { padding: 4px 10px; border: 0; border-right: 1px solid var(--border-color); background: none; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; cursor: pointer; }
.dd-seg-btn:last-child { border-right: 0; }
.dd-seg-btn[aria-pressed="true"] { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.dd-switch-row { cursor: pointer; }
.dd-switch { position: relative; flex: none; width: 36px; height: 20px; }
.dd-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.dd-switch-track { position: absolute; inset: 0; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-tertiary); transition: background 0.15s; }
.dd-switch-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--text-muted); transition: transform 0.15s, background 0.15s; }
.dd-switch input:checked + .dd-switch-track { background: var(--accent-cyan-dim); border-color: var(--accent-cyan); }
.dd-switch input:checked + .dd-switch-track::after { transform: translateX(16px); background: var(--accent-cyan); }
.dd-switch input:focus-visible + .dd-switch-track { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
.dd-switch input:disabled + .dd-switch-track { opacity: 0.5; }
.dd-rc { color: var(--accent-purple); border-color: color-mix(in srgb, var(--accent-purple) 50%, transparent); }
.dd-danger-text { color: var(--accent-red) !important; }