feat: session status at a glance, file & image drop, live Ops Alerts (0.4.0)
build-deb / Lint, test, package (push) Successful in 1m51s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped

- devdesk-state-hook (Claude UserPromptSubmit/PostToolUse/Notification/Stop/
  SessionStart/SessionEnd) records whether each session is working, waiting on
  you or idle; --list carries it. The sidebar shows it, the header shows the
  current session's state, and a pulsing "needs you" chip jumps to sessions
  waiting for input. Session refresh no longer depends on the sidebar being open.
- `devdesk attach --upload SESSION FILE BYTES` receives a file as base64
  terminal input (raw mode, exact length, size-checked, sanitised name) into
  /var/lib/devdesk/uploads/<session>/. The UI uploads dropped or pasted files and
  pastes the path into Claude, which attaches images itself.
- `devdesk alerts` / `--alerts`: firing and pending Grafana alert rules with
  severity, instances, summary and a link. The panel's "Ask Claude" types an
  investigation prompt into the current session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:26:06 -04:00
co-authored by Claude Opus 5.5
parent 531b640ae9
commit 03e4a49e78
14 changed files with 625 additions and 44 deletions
+64
View File
@@ -0,0 +1,64 @@
// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`),
// each with an "Ask Claude" button that types an investigation prompt into the
// current session (without pressing Enter).
import { el } from './sessions.js';
const SEVERITY = {
critical: 'lt-chip-critical',
warning: 'lt-chip-warn',
};
function age(iso) {
const t = Date.parse(iso || '');
if (Number.isNaN(t)) return '';
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
export function investigatePrompt(alert) {
const where = alert.instances?.length ? ` on ${alert.instances.join('; ')}` : '';
return `Investigate the ${alert.state} Grafana alert "${alert.name}"`
+ ` (${alert.severity || 'no severity'}, ${alert.folder} / ${alert.group}${where}, since ${alert.since}).`
+ ` Summary: ${alert.summary || 'n/a'}. Use the grafana MCP server to look at the rule and the related`
+ ' metrics, find the cause, and tell me what you would change before changing anything.';
}
export function renderAlerts(root, data) {
const list = root.querySelector('.dd-alert-list');
const note = root.querySelector('.dd-alerts-note');
const count = root.querySelector('.dd-alert-count');
const alerts = data?.alerts ?? [];
count.textContent = alerts.length ? String(alerts.length) : '';
count.className = `lt-badge lt-badge-sm dd-alert-count${alerts.some((a) => a.severity === 'critical') ? ' lt-badge-p1' : alerts.length ? ' lt-badge-p2' : ''}`;
if (!data) {
root.dataset.state = 'empty';
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Loading…'));
note.textContent = '';
return;
}
if (!data.configured) {
root.dataset.state = 'empty';
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Grafana is not configured.'));
note.textContent = 'Set DEVDESK_GRAFANA_URL and a token file in /etc/devdesk/env.local.';
return;
}
root.dataset.state = 'live';
note.textContent = data.error ? `Grafana unreachable: ${data.error}` : '';
if (!alerts.length) {
list.replaceChildren(el('li', { class: 'dd-all-clear' },
el('span', { class: 'lt-dot lt-dot-up' }), data.error ? 'No data' : 'All clear: nothing firing'));
return;
}
list.replaceChildren(...alerts.map((a) => el('li', { class: `dd-alert is-${a.state}` },
el('div', { class: 'dd-alert-head' },
el('span', { class: `lt-chip ${SEVERITY[a.severity] || 'lt-chip-info'}` }, a.state === 'pending' ? 'pending' : (a.severity || 'alert')),
el('a', { class: 'dd-alert-name', href: a.url, target: '_blank', rel: 'noopener', title: `${a.folder} / ${a.group}` }, a.name),
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
}
+67
View File
@@ -124,6 +124,73 @@ export async function fetchSessionList() {
return parseListOutput(await runCommand([LIST_ARG]));
}
// Firing/pending Grafana alerts (`devdesk attach --alerts`).
export async function fetchAlerts() {
return parseListOutput(await runCommand(['--alerts']));
}
function toBase64(bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Upload a file into the session's upload folder through ttyd: wait for the
// server's ready line, stream the file as base64 input, read back {ok, path}.
export async function uploadFile(session, file, onProgress) {
const token = await fetchToken();
const bytes = new Uint8Array(await file.arrayBuffer());
const b64 = toBase64(bytes);
const name = (file.name || 'pasted-image.png').slice(-100);
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(bytes.length)]), ['tty']);
socket.binaryType = 'arraybuffer';
const decoder = new TextDecoder();
let output = '';
let sending = false;
const timer = setTimeout(() => socket.close(), 30000 + bytes.length / 50);
const send = async () => {
const chunk = 48 * 1024;
for (let i = 0; i < b64.length; i += chunk) {
while (socket.bufferedAmount > 1 << 20) await sleep(20);
if (socket.readyState !== WebSocket.OPEN) return;
socket.send(encodeInput(b64.slice(i, i + chunk)));
onProgress?.(Math.min(1, (i + chunk) / b64.length));
}
};
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
output += decoder.decode(payload, { stream: true });
if (!sending && output.includes('DEVDESK-UPLOAD-READY')) {
sending = true;
send();
}
};
socket.onerror = () => {};
socket.onclose = () => {
clearTimeout(timer);
const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''));
try {
const result = parseListOutput(text);
if (result.ok && result.path) {
resolve(result.path);
return;
}
} catch {
// fall through to the error message
}
const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
reject(new Error((message || 'upload failed').replace(/^devdesk:\s*/, '')));
};
});
}
// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`).
export async function fetchRepo(session) {
return parseListOutput(await runCommand(['--repo', session]));
+119 -3
View File
@@ -1,8 +1,11 @@
/* global __DEVDESK_VERSION__ */
import { createTerminal, loadFonts } from './term.js';
import { TtydConnection, fetchRepo, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, sessionFromSearch } from './protocol.js';
import { SessionsPanel, rememberSession } from './sessions.js';
import { SessionsPanel, rememberSession, sessionStatus } from './sessions.js';
import { renderAlerts } from './alerts.js';
import { renderContext } from './context.js';
import { renderRepo } from './repo.js';
import { bindAskClaude } from './widgets.js';
@@ -34,6 +37,8 @@ const ui = {
wsLabel: $('ws-label'),
sso: $('sso-badge'),
sessionChip: $('session-chip'),
stateChip: $('state-chip'),
attention: $('attention-chip'),
paneTitle: $('pane-title'),
overlay: $('overlay'),
overlayTitle: $('overlay-title'),
@@ -95,6 +100,107 @@ function showContext() {
const data = sessionsPanel?.data;
const current = data?.sessions?.find((s) => s.name === state.session);
renderContext($('widget-context'), state.session, current?.context ?? null);
showSessionState(data, current);
}
// Header: what Claude is doing here, and which other sessions are waiting on you.
function showSessionState(data, current) {
const status = current ? sessionStatus(current) : null;
const key = status && ['working', 'waiting', 'idle'].includes(status.key) ? status.key : '';
ui.stateChip.dataset.state = key;
ui.stateChip.textContent = key ? status.label : '';
ui.stateChip.title = current?.status?.message || '';
const waiting = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting');
ui.attention.classList.toggle('is-active', waiting.length > 0);
ui.attention.textContent = waiting.length === 1 ? `${waiting[0].name} needs you` : `${waiting.length} need you`;
ui.attention.title = waiting.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n');
ui.attention.dataset.target = waiting[0]?.name || '';
}
let alertsBusy = false;
async function refreshAlerts() {
if (alertsBusy) return;
alertsBusy = true;
try {
renderAlerts($('widget-alerts'), await fetchAlerts());
} catch {
// Keep the last rendering; the next refresh retries.
} finally {
alertsBusy = false;
}
}
// ---------------------------------------------------------------- file drop / paste
const uploadUi = $('upload-status');
let uploadHideTimer = 0;
function uploadMessage(text, isError = false, sticky = false) {
clearTimeout(uploadHideTimer);
uploadUi.hidden = false;
uploadUi.textContent = text;
uploadUi.classList.toggle('is-error', isError);
if (!sticky) uploadHideTimer = setTimeout(() => { uploadUi.hidden = true; }, isError ? 8000 : 4000);
}
async function attachFiles(files) {
if (!files.length) return;
if (!state.session || !state.conn?.isOpen) {
uploadMessage('Open a session first: files are attached to the current session.', true);
return;
}
const session = state.session;
const paths = [];
for (const file of files) {
try {
const path = await uploadFile(session, file, (p) =>
uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true));
paths.push(path);
} catch (error) {
uploadMessage(`${file.name || 'file'}: ${error.message}`, true);
return;
}
}
if (session !== state.session) return;
// A pasted image path makes Claude attach the image itself.
term.paste(`${paths.join(' ')} `);
term.focus();
uploadMessage(paths.length === 1 ? `Attached ${paths[0]}` : `Attached ${paths.length} files`);
}
function wireUploads() {
const frame = document.querySelector('.dd-terminal-frame');
const overlay = $('drop-overlay');
let depth = 0;
const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
frame.addEventListener('dragenter', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
depth += 1;
$('drop-session').textContent = state.session || 'a session';
overlay.hidden = false;
});
frame.addEventListener('dragover', (event) => {
if (hasFiles(event)) event.preventDefault();
});
frame.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (!depth) overlay.hidden = true;
});
frame.addEventListener('drop', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
depth = 0;
overlay.hidden = true;
attachFiles([...event.dataTransfer.files]);
});
// Pasting a screenshot / copied file: upload it instead of pasting text.
document.addEventListener('paste', (event) => {
const files = [...(event.clipboardData?.files ?? [])];
if (!files.length) return;
event.preventDefault();
event.stopPropagation();
attachFiles(files);
}, true);
}
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
@@ -361,9 +467,16 @@ function wireChrome() {
document.addEventListener('visibilitychange', () => {
if (!document.hidden) retrySoon();
});
// Session states feed the header chips too, so refresh even with the sidebar closed.
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh();
if (!document.hidden) sessionsPanel.refresh();
}, 15 * 1000);
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshAlerts();
}, 30 * 1000);
ui.attention.addEventListener('click', () => {
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
});
}
// ---------------------------------------------------------------- boot
@@ -435,6 +548,9 @@ async function boot() {
term.focus();
});
wireChrome();
wireUploads();
sessionsPanel.refresh();
refreshAlerts();
connect(state.session);
}
+23 -5
View File
@@ -57,6 +57,20 @@ export function el(tag, attrs = {}, ...children) {
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
// waiting | working | idle | exited | running | dormant.
export function sessionStatus(s) {
if (!s?.running) return { key: 'dormant', label: 'dormant', dot: 'lt-dot-idle' };
switch (s.status?.state) {
case 'waiting': return { key: 'waiting', label: 'needs you', dot: 'lt-dot-down' };
case 'working': return { key: 'working', label: 'working', dot: 'lt-dot-warn' };
case 'idle': return { key: 'idle', label: 'idle', dot: 'lt-dot-up' };
case 'exited': return { key: 'exited', label: 'Claude exited', dot: 'lt-dot-idle' };
default: return { key: 'running', label: 'running', dot: 'lt-dot-up' };
}
}
export class SessionsPanel {
// hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data)
constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) {
@@ -156,15 +170,19 @@ export class SessionsPanel {
renderSession(s) {
const isCurrent = s.name === this.current;
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
const status = sessionStatus(s);
const ctx = s.context?.used_pct != null ? `ctx ${Math.floor(s.context.used_pct)}%` : '';
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
const meta = s.running
? `${s.attached} attached · ${ago(s.activity)}${ctx}`
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, s.attached ? `${s.attached} open` : '']
.filter(Boolean).join(' · ')
: 'dormant · resumes Claude';
const title = [s.dir, status.key === 'waiting' && s.status?.message].filter(Boolean).join('\n');
const iconButton = (label, glyph, onclick, extra = '') =>
el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph);
return el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}${s.running ? '' : ' is-dormant'}` },
el('button', { class: 'dd-session-open', type: 'button', title: s.dir || '', onclick: () => this.hooks.onOpen(s.name) },
el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }),
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
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),
el('span', { class: 'dd-session-meta' }, meta)),
el('span', { class: 'dd-session-actions' },