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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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