feat: v0.5.0 header, switcher, phone key bar, shared polling, Matrix "needs you"
- 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:
+47
-14
@@ -3,6 +3,7 @@
|
||||
|
||||
import { fetchSessionList, sessionAction } from './connection.js';
|
||||
import { slugify } from './protocol.js';
|
||||
import { share } from './shared.js';
|
||||
|
||||
const RECENTS_KEY = 'devdesk.recents';
|
||||
const MAX_RECENTS = 12;
|
||||
@@ -24,6 +25,11 @@ function writeRecents(recents) {
|
||||
}
|
||||
}
|
||||
|
||||
// Session names this browser used most recently, newest first.
|
||||
export function recentNames() {
|
||||
return readRecents().map((r) => r.name);
|
||||
}
|
||||
|
||||
export function rememberSession(name) {
|
||||
if (name) writeRecents([{ name, at: Date.now() }, ...readRecents().filter((r) => r.name !== name)]);
|
||||
}
|
||||
@@ -72,20 +78,26 @@ export function sessionStatus(s) {
|
||||
}
|
||||
|
||||
export class SessionsPanel {
|
||||
// hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data)
|
||||
constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) {
|
||||
Object.assign(this, { list, nameInput, select, status, hooks });
|
||||
// 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 });
|
||||
this.current = null;
|
||||
this.data = null;
|
||||
this.busy = false;
|
||||
this.pending = null;
|
||||
refreshButton.addEventListener('click', () => this.refresh());
|
||||
pickerButton.addEventListener('click', () => hooks.onPicker());
|
||||
newToggle.addEventListener('click', () => this.toggleForm());
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
this.createFromForm();
|
||||
});
|
||||
}
|
||||
|
||||
toggleForm(open = this.form.hidden) {
|
||||
this.form.hidden = !open;
|
||||
this.newToggle.setAttribute('aria-expanded', String(open));
|
||||
if (open) this.nameInput.focus();
|
||||
}
|
||||
|
||||
setCurrent(name) {
|
||||
this.current = name;
|
||||
if (this.data) this.render();
|
||||
@@ -96,19 +108,31 @@ export class SessionsPanel {
|
||||
this.status.classList.toggle('is-error', isError);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.busy) return;
|
||||
this.busy = true;
|
||||
// Concurrent callers share the request in flight (boot awaits the first list).
|
||||
refresh() {
|
||||
this.pending ??= this.load().finally(() => { this.pending = null; });
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
async load() {
|
||||
let fetched = false;
|
||||
try {
|
||||
this.data = await fetchSessionList();
|
||||
fetched = true;
|
||||
share('sessions', this.data);
|
||||
if (!this.status.classList.contains('is-error')) this.setStatus('');
|
||||
} catch (err) {
|
||||
this.setStatus(`list unavailable: ${err.message}`, true);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
this.render();
|
||||
this.hooks.onData?.(this.data);
|
||||
this.hooks.onData?.(this.data, { fetched });
|
||||
}
|
||||
|
||||
// A list another tab fetched (devdesk#8).
|
||||
accept(data) {
|
||||
this.data = data;
|
||||
this.render();
|
||||
this.hooks.onData?.(this.data, { fetched: false });
|
||||
}
|
||||
|
||||
async act(label, action, ...args) {
|
||||
@@ -135,6 +159,7 @@ export class SessionsPanel {
|
||||
if (project && !(await this.act('creating', 'new', name, project))) return;
|
||||
this.nameInput.value = '';
|
||||
this.select.value = '';
|
||||
this.toggleForm(false);
|
||||
this.hooks.onOpen(name);
|
||||
}
|
||||
|
||||
@@ -173,18 +198,26 @@ export class SessionsPanel {
|
||||
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 model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : '';
|
||||
const meta = s.running
|
||||
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, s.attached ? `${s.attached} open` : '']
|
||||
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, 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 pct = s.context?.used_pct;
|
||||
const title = [
|
||||
s.dir,
|
||||
status.key === 'waiting' && s.status?.message,
|
||||
s.context?.model && `${s.context.model}${s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`,
|
||||
].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 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-meta' }, meta),
|
||||
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' },
|
||||
iconButton('Rename', '✎', () => this.rename(s.name)),
|
||||
el('a', {
|
||||
|
||||
Reference in New Issue
Block a user