feat: v0.10.0 quieter Matrix, worktrees, usage pace, repo diffs, copy reply, turn timer, compact
build-deb / Notify on failure (push) Successful in 4s
build-deb / Lint, test, package (push) Failing after 2m22s
build-deb / Deploy to CT 126 (push) Skipped

- Matrix "needs you": ignore Claude's idle reminder (no red, no post); post
  only when no tab is in view, after 60 s, once per session per 10 min, and
  say what is asked. Closes #50
- devdesk new NAME PROJECT --worktree: own checkout on devdesk/NAME,
  cleaned up on end when clean/merged. Closes #44
- Usage meters: reset time, pace warning; Matrix ping past 90% when away.
  Closes #45
- Repo panel lists changed files; click opens the diff. Closes #46
- Copy Claude's last reply (--last-reply). Closes #47
- Turn timer in chip, title and cards. Closes #48
- Compact button at 80% context when idle. Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
This commit is contained in:
2026-10-11 23:28:11 -04:00
co-authored by Claude Opus 5.5
parent bf8821f3f9
commit a2edec966a
17 changed files with 556 additions and 78 deletions
+8
View File
@@ -224,6 +224,14 @@ export async function peekSession(session) {
return parseListOutput(await runCommand(['--peek', session], 15000));
}
// Claude's whole last answer, as Markdown (devdesk#47): {session, text, at}.
export async function lastReply(session) {
const output = stripAnsi(await runCommand(['--last-reply', session], 15000)).trim();
if (output.includes('"session"')) return parseListOutput(output);
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
export async function replyTo(session, text) {
const bytes = new TextEncoder().encode(text);
const result = await limited(() => streamIn(['--reply', session, String(bytes.length)], 'DEVDESK-REPLY-READY', new Blob([bytes])));
+85 -8
View File
@@ -2,10 +2,11 @@
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, lastReply, replyTo, sessionAction, uploadFile,
AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { SessionsPanel, ctxLabel, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { SessionsPanel, ctxLabel, elapsed, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { renderAlerts } from './alerts.js';
import { renderRepo } from './repo.js';
import { bindAskClaude } from './widgets.js';
@@ -125,10 +126,17 @@ function renderSessionChip() {
const status = current ? sessionStatus(current) : null;
ui.sessionName.textContent = state.session || 'picker';
ui.sessionChip.dataset.state = status?.key ?? '';
// How long the current turn has been running (devdesk#48).
const turn = status?.key === 'working' && current.status?.since ? elapsed(current.status.since) : '';
const timer = $('turn-timer');
timer.hidden = !turn;
timer.textContent = turn;
notify.setStatusPrefix(turn ? `⏳ ${turn} · ` : status?.key === 'waiting' ? '❗ ' : '');
ui.sessionChip.title = [
state.session ? `Session ${state.session}${status ? ` · ${status.label}` : ''}` : 'Session picker',
current?.status?.message,
current?.context?.used_pct != null ? `context ${Math.floor(current.context.used_pct)}%` : '',
turn ? `working on this turn for ${turn}` : '',
current?.context?.used_pct != null ? ctxLabel(current.context).replace(/^ctx/, 'context') : '',
'Click to switch session',
].filter(Boolean).join('\n');
}
@@ -285,6 +293,14 @@ function wireUploads() {
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
let lastCi = { key: '', state: '' };
// Repo panel → diffs in the file pane (devdesk#46).
const repoHooks = {
openChanges: () => files.open('changes'),
openDiff: (path, diffable) => {
files.open('changes');
files.openFile(path, { mode: diffable ? 'diff' : undefined, pin: true });
},
};
let repoBusy = false;
async function refreshRepo() {
const session = state.session;
@@ -298,7 +314,7 @@ async function refreshRepo() {
try {
const repo = await fetchRepo(session);
if (session !== state.session) return;
renderRepo($('widget-repo'), repo);
renderRepo($('widget-repo'), repo, repoHooks);
lastRepo = repo;
const key = `${session}@${repo.sha}`;
const ci = repo.ci?.total ? repo.ci.state : '';
@@ -646,15 +662,28 @@ function renderUsage() {
return;
}
const at = (epoch) => (epoch ? new Date(epoch * 1000).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '?');
const meter = (label, w) => {
const clock = (epoch) => new Date(epoch * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const day = (epoch) => new Date(epoch * 1000).toLocaleDateString(undefined, { weekday: 'short' });
// devdesk#45: when it resets, and when it fills at the pace of the window so
// far (straight line from the window's start), if that is before the reset.
const meter = (label, w, span, short) => {
if (!w || w.pct === null || w.pct === undefined) return null;
const pct = Math.round(w.pct);
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 ? ' is-high' : ''}`, title: `${label}: ${pct}% used, resets ${at(w.resets_at)}` },
const now = Date.now() / 1000;
const start = w.resets_at ? w.resets_at - span : null;
const used = start ? now - start : 0;
const full = start && w.pct >= 5 && w.pct < 100 && used >= 600 ? now + ((100 - w.pct) * used) / w.pct : null;
const early = full && full < w.resets_at ? full : null;
const title = [`${label}: ${pct}% used, resets ${at(w.resets_at)}`,
early ? `At this pace it is used up around ${at(early)}, before it resets` : start && pct < 100 ? 'At this pace it lasts until it resets' : ''].filter(Boolean).join('\n');
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 || early ? ' is-high' : ''}`, title },
el('span', { class: 'dd-meter-label' }, label),
el('span', { class: 'dd-meter-bar' }, el('span', { class: 'dd-meter-fill', style: `width:${Math.min(100, pct)}%` })),
`${pct}%`);
`${pct}%`,
early ? el('span', { class: 'dd-meter-pace' }, `⚠ full ~${short(early)}`)
: w.resets_at ? el('span', { class: 'dd-meter-reset' }, `↻ ${short(w.resets_at)}`) : null);
};
const parts = () => [meter('5h', latest.five_hour), meter('week', latest.seven_day)].filter(Boolean);
const parts = () => [meter('5h', latest.five_hour, 5 * 3600, clock), meter('week', latest.seven_day, 7 * 86400, day)].filter(Boolean);
footer.replaceChildren(...parts());
$('usage-menu').replaceChildren(...parts());
footer.hidden = false;
@@ -683,6 +712,44 @@ function presenceTick() {
const sendKeys = (keys) => state.conn?.sendInput(keys);
// Claude's whole last answer as Markdown (devdesk#47). The clipboard write starts
// inside the click (Safari needs that) and gets its text when the server answers.
async function copyReply(session) {
const text = lastReply(session).then((r) => {
if (!r.text) throw new Error(`${session}: Claude hasn't answered anything yet`);
return r.text;
});
try {
if (window.ClipboardItem && navigator.clipboard?.write) {
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': text.then((t) => new Blob([t], { type: 'text/plain' })) })]);
} else {
await navigator.clipboard.writeText(await text);
}
const t = await text;
toast.success(`Copied ${session}'s last reply (${t.length.toLocaleString()} characters)`);
} catch (error) {
const reason = await text.then(() => null, (e) => e);
toast.error(reason ? reason.message : `Copy failed: ${error.message}`);
}
}
// /compact in a session whose context is filling up (devdesk#49). Only when Claude
// is idle: mid-turn or at a permission prompt the keys would land in the wrong place.
async function compact(session) {
const s = sessionsPanel?.data?.sessions?.find((x) => x.name === session);
const key = s ? sessionStatus(s).key : 'idle';
if (key === 'working') return toast.warning(`${session} is working: compact once the turn ends`);
if (key === 'waiting') return toast.warning(`${session} is waiting on you: answer it first`);
try {
await replyTo(session, '/compact');
toast.success(`Compacting ${session}'s context`);
sessionsPanel.reload();
} catch (error) {
toast.error(`${session}: ${error.message}`);
}
return undefined;
}
async function copyLink() {
const url = new URL(window.location.href);
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
@@ -723,6 +790,8 @@ function buildCommands() {
run: () => sessionsPanel.end(current ?? { name: state.session, running: true }),
});
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
add({ id: 'copy-reply', group: 'Sessions', icon: '⧉', label: 'Copy Claude\'s last reply (Markdown)', keywords: 'clipboard answer response markdown', run: () => copyReply(state.session) });
if (current?.running) add({ id: 'compact', group: 'Sessions', icon: '⇲', label: `Compact the context (/compact)${current.context?.used_pct != null ? ` · ${Math.floor(current.context.used_pct)}% full` : ''}`, keywords: 'context summarize shrink', run: () => compact(state.session) });
}
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
add({ id: 'history', group: 'Sessions', icon: '⌕', label: 'Search past conversations…', keywords: 'history transcript resume fork find old', run: showHistory });
@@ -1084,6 +1153,11 @@ function wireChrome() {
// so poll even in a background tab (browsers throttle that to about once a
// minute) — unless another tab just shared a fresh list (devdesk#8, #15).
onShared('sessions', (data) => sessionsPanel.accept(data));
// The turn timer and the usage pace move between polls.
setInterval(() => {
renderSessionChip();
renderUsage();
}, 10 * 1000);
setInterval(() => {
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
}, 15 * 1000);
@@ -1161,6 +1235,7 @@ async function boot() {
form: $('new-session-form'),
nameInput: $('new-session-name'),
select: $('project-select'),
worktree: $('new-session-worktree'),
refreshButton: $('sessions-refresh'),
newToggle: $('new-session-toggle'),
status: $('sessions-status'),
@@ -1170,6 +1245,8 @@ async function boot() {
connect(name);
},
onEnding: () => { state.endingCurrent = true; },
onCompact: (name) => compact(name),
onCopyReply: (name) => copyReply(name),
onEnded: (name) => {
if (name !== state.session) state.endingCurrent = false;
},
+9 -1
View File
@@ -6,13 +6,14 @@ import { FAVICON, FAVICON_BADGE } from './icon.js';
let unread = 0;
let baseTitle = document.title;
let statusPrefix = '';
function attentive() {
return !document.hidden && document.hasFocus();
}
function render() {
document.title = unread ? `(${unread}) ${baseTitle}` : baseTitle;
document.title = `${unread ? `(${unread}) ` : ''}${statusPrefix}${baseTitle}`;
const link = document.getElementById('favicon');
if (link) link.href = unread ? FAVICON_BADGE : FAVICON;
}
@@ -22,6 +23,13 @@ export function setBaseTitle(title) {
render();
}
// "⏳ 3m · " while Claude works on a turn in this tab's session (devdesk#48).
export function setStatusPrefix(prefix) {
if (prefix === statusPrefix) return;
statusPrefix = prefix;
render();
}
export function notificationsState() {
if (!('Notification' in window)) return 'unsupported';
return Notification.permission; // granted | denied | default
+46 -2
View File
@@ -18,13 +18,16 @@ function link(href, text) {
return a;
}
export function renderRepo(root, repo) {
// hooks: openDiff(path, diffable), openChanges() (devdesk#46)
export function renderRepo(root, repo, hooks = {}) {
const field = (name) => root.querySelector(`[data-field="${name}"]`);
const set = (name, value) => field(name).replaceChildren(value);
const grid = root.querySelector('.dd-repo-grid');
const note = root.querySelector('.dd-repo-note');
const fix = root.querySelector('.dd-fix-ci');
fix.hidden = true;
const changes = root.querySelector('.dd-repo-changes');
changes.hidden = true;
// No repository: one line instead of a grid of dashes (devdesk#17).
if (!repo || !repo.git) {
@@ -41,7 +44,48 @@ export function renderRepo(root, repo) {
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
: 'no upstream';
set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`);
set('dirty', repo.dirty ? `${repo.dirty} changed` : 'clean');
if (repo.dirty) {
const all = document.createElement('button');
all.type = 'button';
all.className = 'dd-link-btn';
all.textContent = `${repo.dirty} changed`;
all.title = 'Review all changes';
all.addEventListener('click', () => hooks.openChanges?.());
set('dirty', all);
} else {
set('dirty', 'clean');
}
// The first changed files: click one for its diff against HEAD.
const files = (repo.changes ?? []).filter((f) => f.path !== null && f.path !== undefined);
if (files.length) {
changes.hidden = false;
changes.replaceChildren(...files.map((f) => {
const diffable = f.x !== '?' && f.y !== '?';
const code = f.x === '?' ? 'new' : (f.x !== ' ' ? f.x : f.y);
const li = document.createElement('li');
const b = document.createElement('button');
b.type = 'button';
b.className = 'dd-repo-change';
b.title = `${f.path}${diffable ? ' · diff against HEAD' : ' · new file'}${f.claude ? ' · edited by Claude' : ''}`;
const name = document.createElement('span');
name.className = 'dd-repo-change-name';
name.textContent = f.path;
const stat = document.createElement('span');
stat.className = 'dd-repo-change-stat';
stat.textContent = [f.add ? `+${f.add}` : '', f.del ? `−${f.del}` : '', code === 'new' ? 'new' : code.trim()]
.filter(Boolean).join(' ');
b.append(name, stat);
b.addEventListener('click', () => hooks.openDiff?.(f.path, diffable));
li.append(b);
return li;
}));
if (repo.dirty > files.length) {
const more = document.createElement('li');
more.className = 'dd-muted';
more.textContent = `+${repo.dirty - files.length} more`;
changes.append(more);
}
}
const ci = field('ci');
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
+42 -11
View File
@@ -46,6 +46,14 @@ function renameRecent(from, to) {
writeRecents(readRecents().map((r) => (r.name === from ? { ...r, name: to } : r)));
}
// 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`;
}
// "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) {
@@ -79,9 +87,11 @@ export function sessionStatus(s) {
}
export class SessionsPanel {
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data)
constructor({ list, form, nameInput, select, refreshButton, newToggle, status, hooks }) {
Object.assign(this, { list, form, nameInput, select, newToggle, status, hooks });
// 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));
this.current = null;
this.data = null;
this.pending = null;
@@ -153,6 +163,14 @@ export class SessionsPanel {
}
}
// 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;
}
async createFromForm() {
const project = this.select.value;
const name = slugify(this.nameInput.value) || project;
@@ -161,16 +179,17 @@ export class SessionsPanel {
this.nameInput.focus();
return;
}
if (!(await this.create(name, project))) return;
if (!(await this.create(name, project, this.worktree?.checked))) return;
this.nameInput.value = '';
this.select.value = '';
this.worktreeOption(this.select, this.worktree);
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;
async create(name, project, worktree = false) {
if (project && !(await this.act('new', name, project, ...(worktree ? ['--worktree'] : [])))) return false;
this.hooks.onOpen(name);
return true;
}
@@ -191,14 +210,19 @@ export class SessionsPanel {
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.');
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'));
project.addEventListener('change', () => {
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
this.worktreeOption(project, worktree);
});
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),
worktreeLabel,
hint),
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
submit: () => {
@@ -209,10 +233,10 @@ export class SessionsPanel {
name.focus();
return null;
}
return { name: value, project: project.value };
return { name: value, project: project.value, worktree: worktree.checked };
},
});
if (result) await this.create(result.name, result.project);
if (result) await this.create(result.name, result.project, result.worktree);
}
async rename(name) {
@@ -254,9 +278,10 @@ export class SessionsPanel {
async end(session) {
const ok = await confirmDialog({
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
text: session.running
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.',
: '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.` : ''),
confirmLabel: session.running ? 'End session' : 'Forget',
danger: true,
});
@@ -276,7 +301,8 @@ export class SessionsPanel {
const isCurrent = s.name === this.current;
const status = sessionStatus(s);
const ctx = ctxLabel(s.context);
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
const since = status.key === 'working' && s.status?.since ? elapsed(s.status.since)
: s.status?.since ? ago(s.status.since) : ago(s.activity);
const model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : '';
const meta = s.running
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
@@ -295,6 +321,7 @@ 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,
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)
s.context?.pr?.number ? el('a', {
@@ -306,6 +333,10 @@ export class SessionsPanel {
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),
el('span', { class: 'dd-session-actions' },
// 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,
iconButton('Rename', '✎', () => this.rename(s.name)),
el('a', {
class: 'dd-icon-btn', href: `?arg=${encodeURIComponent(s.name)}`, target: '_blank', rel: 'noopener',