feat: v0.10.0 quieter Matrix, worktrees, usage pace, repo diffs, copy reply, turn timer, compact
- 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:
+42
-11
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user