feat: session status at a glance, file & image drop, live Ops Alerts (0.4.0)
- 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:
+119
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user