feat: v0.5.0 header, switcher, phone key bar, shared polling, Matrix "needs you"
build-deb / Lint, test, package (push) Successful in 2m12s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped

- Header: no SSO chip ("Sign in again" only on expiry), state dot on the
  session chip, LIVE as a dot, ⋯ menu for text size/notifications/fullscreen,
  Panels badge for waiting sessions and alerts.
- Session switcher on the chip; bare URL reattaches the last session; + New
  opens the panel form with project folders.
- Notifications when other sessions start waiting; alerts polled with the
  panels closed; one tab polls for all tabs over BroadcastChannel.
- Sidebar: context bar per session instead of the Context panel; compact
  Repo/CI with "Ask Claude to fix CI"; legible labels.
- Phone: key bar, drawer with backdrop, visualViewport-aware layout, DOM
  renderer on touch, compact connection dot.
- devdesk-notify-matrix: one Matrix message per wait that lasts
  DEVDESK_MATRIX_DELAY seconds with nobody attached.

Closes #8
Closes #13
Closes #14
Closes #15
Closes #16
Closes #17
Closes #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:34:59 -04:00
co-authored by Claude Opus 5.5
parent 7aa624bc13
commit be9b026a01
19 changed files with 838 additions and 257 deletions
+269 -73
View File
@@ -4,11 +4,11 @@ import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { SessionsPanel, rememberSession, sessionStatus } from './sessions.js';
import { SessionsPanel, el, recentNames, 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';
import { heardWithin, onShared, share } from './shared.js';
import * as notify from './notify.js';
const $ = (id) => document.getElementById(id);
@@ -35,21 +35,27 @@ const ui = {
body: document.body,
wsStatus: $('ws-status'),
wsLabel: $('ws-label'),
sso: $('sso-badge'),
reauth: $('reauth'),
sessionChip: $('session-chip'),
stateChip: $('state-chip'),
sessionName: $('session-name'),
switcher: $('switcher'),
attention: $('attention-chip'),
paneTitle: $('pane-title'),
overlay: $('overlay'),
overlayTitle: $('overlay-title'),
overlayText: $('overlay-text'),
overlayActions: $('overlay-actions'),
moreToggle: $('more-toggle'),
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
panelsBadge: $('panels-badge'),
backdrop: $('backdrop'),
};
const urlSession = sessionFromSearch(window.location.search);
const state = {
session: sessionFromSearch(window.location.search), // null = picker
session: urlSession, // null = picker
conn: null,
attempts: 0,
retryTimer: null,
@@ -58,23 +64,22 @@ const state = {
lastActivity: Date.now(),
};
const touch = window.matchMedia('(pointer: coarse)').matches;
const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches;
let term;
let fit;
let sessionsPanel;
let lastAlerts = null;
let waitingOthers = [];
let paneTitleRaw = '';
// ---------------------------------------------------------------- status UI
function setWsState(stateName, label) {
ui.wsStatus.dataset.state = stateName;
ui.wsLabel.textContent = label;
}
function setSso(stateName) {
ui.sso.dataset.state = stateName;
ui.sso.textContent = stateName === 'expired' ? 'SSO expired' : 'SSO';
ui.sso.title = stateName === 'expired'
? 'Authelia session expired. Click to sign in again.'
: 'Signed in through Authelia';
ui.wsStatus.title = label;
}
function showOverlay({ title, text, actions = [], tone = 'info' }) {
@@ -96,25 +101,67 @@ function hideOverlay() {
ui.overlay.hidden = true;
}
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);
// Pane title (Claude's own title) only when it says more than the session name.
function renderPaneTitle() {
const bare = paneTitleRaw.replace(/^[^\p{L}\p{N}]+/u, '').trim();
ui.paneTitle.textContent = bare && bare !== state.session ? paneTitleRaw : '';
}
// Header: what Claude is doing here, and which other sessions are waiting on you.
function showSessionState(data, current) {
function renderSessionChip() {
const data = sessionsPanel?.data;
const current = data?.sessions?.find((s) => s.name === state.session);
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 || '';
ui.sessionName.textContent = state.session || 'picker';
ui.sessionChip.dataset.state = status?.key ?? '';
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)}%` : '',
'Click to switch session',
].filter(Boolean).join('\n');
}
// Other sessions waiting on you: header chip, a notification when one starts
// waiting (devdesk#15), and the Panels badge. Only the tab that fetched the list
// notifies, so several open tabs don't ring once each.
let prevWaiting = new Set();
function renderAttention(fetched) {
const data = sessionsPanel?.data;
waitingOthers = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting');
for (const s of waitingOthers) {
if (fetched && !prevWaiting.has(s.name)) notify.ring(s.name, s.status?.message || 'Claude needs your input');
}
prevWaiting = new Set(waitingOthers.map((s) => s.name));
ui.attention.classList.toggle('is-active', waitingOthers.length > 0);
ui.attention.textContent = waitingOthers.length === 1 ? `${waitingOthers[0].name} needs you` : `${waitingOthers.length} need you`;
ui.attention.title = waitingOthers.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n');
ui.attention.dataset.target = waitingOthers[0]?.name || '';
renderPanelsBadge();
}
function renderPanelsBadge() {
const alerts = lastAlerts?.alerts ?? [];
const count = waitingOthers.length + alerts.length;
const urgent = waitingOthers.length > 0 || alerts.some((a) => a.severity === 'critical');
ui.panelsBadge.hidden = count === 0 || ui.body.classList.contains('dd-sidebar-open');
ui.panelsBadge.textContent = String(count);
ui.panelsBadge.classList.toggle('is-urgent', urgent);
ui.panelsBadge.title = [
waitingOthers.length ? `${waitingOthers.length} session(s) need you` : '',
alerts.length ? `${alerts.length} Grafana alert(s)` : '',
].filter(Boolean).join(' · ');
}
function onSessionData({ fetched = false } = {}) {
renderSessionChip();
renderAttention(fetched);
if (!ui.switcher.hidden) renderSwitcher();
}
function showAlerts(data) {
lastAlerts = data;
renderAlerts($('widget-alerts'), data);
renderPanelsBadge();
}
let alertsBusy = false;
@@ -122,7 +169,8 @@ async function refreshAlerts() {
if (alertsBusy) return;
alertsBusy = true;
try {
renderAlerts($('widget-alerts'), await fetchAlerts());
showAlerts(await fetchAlerts());
share('alerts', lastAlerts);
} catch {
// Keep the last rendering; the next refresh retries.
} finally {
@@ -254,8 +302,9 @@ function connect(session, { reconnect = false } = {}) {
state.session = session;
updateUrl(session);
sessionsPanel?.setCurrent(session);
showContext();
ui.sessionChip.textContent = session || 'picker';
paneTitleRaw = '';
renderPaneTitle();
onSessionData();
hideOverlay();
setWsState('connecting', 'connecting');
state.openedAt = 0;
@@ -268,7 +317,6 @@ function connect(session, { reconnect = false } = {}) {
state.openedAt = Date.now();
state.attempts = 0;
setWsState('connected', 'live');
setSso('ok');
if (session) rememberSession(session);
refreshRepo();
fit.fit();
@@ -323,12 +371,11 @@ function onClosed(code) {
function onFailure(error) {
if (error instanceof AuthExpiredError) {
setSso('expired');
setWsState('disconnected', 'signed out');
showOverlay({
title: 'Sign-in expired',
text: 'Your Authelia session has expired. Claude keeps running on the server; sign in again to reattach.',
actions: [{ label: 'Re-authenticate', primary: true, run: () => window.location.reload() }],
actions: [{ label: 'Sign in again', primary: true, run: () => window.location.reload() }],
tone: 'warn',
});
return;
@@ -373,12 +420,14 @@ function applyTitle() {
titleFrame = 0;
const parsed = parseTitle(pendingTitle);
if (!parsed) {
ui.paneTitle.textContent = pendingTitle;
paneTitleRaw = pendingTitle;
renderPaneTitle();
return;
}
if (parsed.session === 'picker') {
ui.sessionChip.textContent = 'picker';
ui.paneTitle.textContent = 'choose a session';
ui.sessionName.textContent = 'picker';
paneTitleRaw = 'choose a session';
renderPaneTitle();
notify.setBaseTitle('DevDesk · picker');
return;
}
@@ -388,45 +437,152 @@ function applyTitle() {
updateUrl(parsed.session);
rememberSession(parsed.session);
sessionsPanel.setCurrent(parsed.session);
showContext();
onSessionData();
refreshRepo();
}
ui.sessionChip.textContent = parsed.session;
ui.paneTitle.textContent = parsed.pane;
paneTitleRaw = parsed.pane;
renderPaneTitle();
notify.setBaseTitle(`${parsed.session} · DevDesk`);
}
// ---------------------------------------------------------------- menus
const menus = [];
function wireMenu(button, menu, onOpen) {
menus.push({ button, menu });
button.addEventListener('click', (event) => {
event.stopPropagation();
const open = menu.hidden;
closeMenus();
if (open) {
onOpen?.();
menu.hidden = false;
button.setAttribute('aria-expanded', 'true');
}
});
menu.addEventListener('click', (event) => event.stopPropagation());
}
function closeMenus() {
for (const { button, menu } of menus) {
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
}
// Session switcher on the session chip (devdesk#16).
function renderSwitcher() {
const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) =>
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
const choose = (run) => () => {
closeMenus();
run();
};
const items = sessions.map((s) => {
const status = sessionStatus(s);
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
return el('button', {
type: 'button', role: 'menuitem',
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
title: s.dir || '', onclick: choose(() => connect(s.name)),
},
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-switch-name' }, s.name),
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
});
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
ui.switcher.replaceChildren(
...items,
el('div', { class: 'dd-menu-sep' }),
el('button', {
type: 'button', role: 'menuitem', class: 'dd-menu-item',
onclick: choose(() => {
setSidebar(true);
sessionsPanel.toggleForm(true);
}),
}, '+ New session…'),
el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'),
);
}
// ---------------------------------------------------------------- chrome controls
function setFontSize(size) {
const clamped = Math.min(24, Math.max(10, size));
term.options.fontSize = clamped;
store.set('fontSize', clamped);
$('font-size').textContent = String(clamped);
fit.fit();
}
function renderNotifyButton() {
const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' };
const current = notify.notificationsState();
const labels = {
unsupported: 'Desktop notifications: unsupported',
denied: 'Desktop notifications: blocked by the browser',
default: 'Enable desktop notifications',
granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)',
};
ui.notifyButton.textContent = labels[current];
ui.notifyButton.dataset.state = current;
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
}
function setSidebar(open) {
ui.body.classList.toggle('dd-sidebar-open', open);
ui.sidebarButton.setAttribute('aria-expanded', String(open));
store.set('sidebar', open ? 'open' : 'closed');
if (open) sessionsPanel?.refresh();
ui.backdrop.hidden = !(open && isNarrow());
if (!isNarrow()) store.set('sidebar', open ? 'open' : 'closed');
if (open) {
sessionsPanel?.refresh();
refreshAlerts();
refreshRepo();
}
renderPanelsBadge();
}
function closeDrawerIfNarrow() {
if (isNarrow() && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false);
}
// Phone key bar: keys an on-screen keyboard can't send (devdesk#14).
const KEYS = {
esc: '\x1b', tab: '\t', stab: '\x1b[Z', ctrlc: '\x03', up: '\x1b[A', down: '\x1b[B', enter: '\r',
claude: '\x020', shell: '\x021', // tmux prefix (C-b) + window number
};
function wireKeybar() {
if (!touch) return;
ui.body.classList.add('dd-touch');
const bar = $('keybar');
bar.hidden = false;
// Don't steal focus from the terminal (that would close the on-screen keyboard).
bar.addEventListener('pointerdown', (event) => event.preventDefault());
bar.addEventListener('click', (event) => {
const key = event.target.closest('[data-keys]')?.dataset.keys;
if (key && KEYS[key]) state.conn?.sendInput(KEYS[key]);
});
// Keep the terminal above the on-screen keyboard.
const vv = window.visualViewport;
if (vv) {
const update = () => {
const hidden = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
document.documentElement.style.setProperty('--dd-kb', `${hidden}px`);
};
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
}
}
function wireChrome() {
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
ui.notifyButton.addEventListener('click', async () => {
await notify.requestNotifications();
if (notify.notificationsState() === 'default') await notify.requestNotifications();
else if (notify.notificationsState() === 'granted') notify.setMuted(!notify.isMuted());
renderNotifyButton();
});
renderNotifyButton();
$('fullscreen-toggle').addEventListener('click', async () => {
closeMenus();
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
@@ -435,13 +591,37 @@ function wireChrome() {
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
navigator.keyboard?.lock?.().catch(() => {});
});
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
const wide = window.matchMedia('(min-width: 1100px)').matches;
setSidebar(store.get('sidebar', wide ? 'open' : 'closed') === 'open');
ui.sso.addEventListener('click', () => {
if (ui.sso.dataset.state === 'expired') window.location.reload();
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
wireMenu(ui.sessionChip, ui.switcher, () => {
renderSwitcher();
sessionsPanel.refresh();
});
document.addEventListener('click', closeMenus);
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
ui.backdrop.addEventListener('click', () => setSidebar(false));
setSidebar(!isNarrow() && store.get('sidebar', 'open') === 'open');
window.matchMedia('(min-width: 1100px)').addEventListener('change', () => {
ui.backdrop.hidden = !(ui.body.classList.contains('dd-sidebar-open') && isNarrow());
});
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const menuOpen = menus.some(({ menu }) => !menu.hidden);
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
if (!menuOpen && !drawerOpen) return;
event.preventDefault();
event.stopPropagation();
closeMenus();
if (drawerOpen) setSidebar(false);
}, true);
// "Sign in again" appears only when a request finds the SSO session expired (devdesk#13).
window.addEventListener('devdesk:auth', (event) => {
ui.reauth.hidden = event.detail !== 'expired';
});
ui.reauth.addEventListener('click', () => window.location.reload());
// Ctrl+W closes the tab before the terminal sees it; ask first.
window.addEventListener('beforeunload', (event) => {
@@ -455,27 +635,27 @@ function wireChrome() {
document.addEventListener(type, () => { state.lastActivity = Date.now(); }, { capture: true, passive: true });
}
// While you're active, touch the Authelia gate so its inactivity timer doesn't lapse.
setInterval(async () => {
setInterval(() => {
if (Date.now() - state.lastActivity > 10 * 60 * 1000) return;
try {
await fetchToken();
setSso('ok');
} catch (error) {
if (error instanceof AuthExpiredError) setSso('expired');
}
fetchToken().catch(() => {});
}, 3 * 60 * 1000);
window.addEventListener('online', retrySoon);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) retrySoon();
});
// Session states feed the header chips too, so refresh even with the sidebar closed.
// Session states feed the header chip and the notifications for other sessions,
// 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));
setInterval(() => {
if (!document.hidden) sessionsPanel.refresh();
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
}, 15 * 1000);
// Alerts feed the Panels badge too.
onShared('alerts', showAlerts);
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshAlerts();
}, 30 * 1000);
if (!heardWithin('alerts', 62 * 1000)) refreshAlerts();
}, 60 * 1000);
// Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8).
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
@@ -485,11 +665,23 @@ function wireChrome() {
});
}
// A bare URL (no ?arg=) reattaches the session you used last, or the most
// recently active one; the picker is only the fallback (devdesk#16).
function defaultSession(data) {
const sessions = data?.sessions ?? [];
const known = new Set(sessions.map((s) => s.name));
const recent = recentNames().find((name) => known.has(name));
if (recent) return recent;
const running = sessions.filter((s) => s.running).sort((a, b) => (b.activity || 0) - (a.activity || 0));
return running[0]?.name ?? null;
}
// ---------------------------------------------------------------- boot
async function boot() {
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
$('font-size').textContent = String(fontSize);
await loadFonts(fontSize);
({ term, fit } = createTerminal($('terminal'), {
@@ -504,8 +696,8 @@ async function boot() {
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
});
term.onBell(() => {
notify.ring(state.session, ui.paneTitle.textContent);
refreshSoon(); // Claude finished a turn: context numbers just changed
notify.ring(state.session, ui.paneTitle.textContent || 'Claude needs your attention');
refreshSoon(); // Claude finished a turn: states and context just changed
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
});
@@ -526,11 +718,13 @@ async function boot() {
nameInput: $('new-session-name'),
select: $('project-select'),
refreshButton: $('sessions-refresh'),
pickerButton: $('open-picker'),
newToggle: $('new-session-toggle'),
status: $('sessions-status'),
hooks: {
onOpen: (name) => connect(name),
onPicker: () => connect(null),
onOpen: (name) => {
closeDrawerIfNarrow();
connect(name);
},
onEnding: () => { state.endingCurrent = true; },
onEnded: (name) => {
if (name !== state.session) state.endingCurrent = false;
@@ -541,23 +735,25 @@ async function boot() {
state.session = to;
updateUrl(to);
rememberSession(to);
ui.sessionChip.textContent = to;
sessionsPanel.setCurrent(to);
renderSessionChip();
},
onData: () => showContext(),
onData: (_data, meta) => onSessionData(meta),
},
});
bindAskClaude($('sidebar'), (text) => {
// Alert text is untrusted: no control characters (a newline would run it in a
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
term.paste(pasteSafe(text));
closeDrawerIfNarrow();
term.focus();
});
wireChrome();
wireKeybar();
wireUploads();
sessionsPanel.refresh();
refreshAlerts();
connect(state.session);
await sessionsPanel.refresh();
connect(urlSession ?? defaultSession(sessionsPanel.data));
}
boot();