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
+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]));