Adds a compact shortcuts row inside the composer panel for one-click
instruction queuing, with full lifecycle management stored in localStorage.
Features
--------
- Six built-in defaults (Stop, Summarize, Explain error, Undo, Continue, etc.)
- Click any chip → instantly POSTs to /api/instructions, no typing required
- Cyan border pulse on fire; green glow flash on success
- Edit mode (toggle button in header):
- Per-chip edit (✏) button → replaces chip with inline input, Enter to save
- Per-chip delete (✕) button → removes with vanish animation
- '+ Add' chip → inline form appended below rail
- All changes persisted to localStorage key 'local-mcp-shortcuts'
- Accessible: button elements, aria-labels, keyboard support (Enter/Escape)
Files
-----
- static/js/shortcuts.js new module (loadShortcuts, renderShortcuts,
startInlineEdit, showAddPrompt, initShortcuts)
- static/index.html #shortcuts-container inside composer .panel-body
- static/js/app.js import + initShortcuts() in bootstrap()
- static/css/components.css .shortcuts-container, .shortcut-chip variants,
.shortcut-inline-edit, keyframes chip-fire/sent/vanish
199 lines
7.0 KiB
JavaScript
199 lines
7.0 KiB
JavaScript
/**
|
||
* static/js/app.js
|
||
* Application bootstrap – initialises all modules, kicks off data fetching,
|
||
* and exports shared utilities like `toast`.
|
||
*/
|
||
|
||
import { api, setUnauthorizedHandler, getStoredToken, setStoredToken } from './api.js';
|
||
import { state } from './state.js';
|
||
import { connectSSE } from './events.js';
|
||
import { initInstructions, initComposer, refreshTimestamps } from './instructions.js';
|
||
import { initStatus, initConfig, refreshStatusTimestamps } from './status.js';
|
||
import { initTheme } from './theme.js';
|
||
import { initShortcuts } from './shortcuts.js';
|
||
|
||
// ── Toast notification ────────────────────────────────────────────────────
|
||
|
||
const _toastContainer = document.getElementById('toast-container');
|
||
|
||
export function toast(message, type = 'info') {
|
||
const el = document.createElement('div');
|
||
el.className = `toast toast--${type}`;
|
||
el.textContent = message;
|
||
_toastContainer.appendChild(el);
|
||
setTimeout(() => {
|
||
el.style.animation = 'toast-out 240ms cubic-bezier(0.4,0,1,1) forwards';
|
||
el.addEventListener('animationend', () => el.remove());
|
||
}, 3000);
|
||
}
|
||
|
||
// ── Token auth modal ──────────────────────────────────────────────────────
|
||
|
||
function showTokenModal(onSuccess) {
|
||
// Remove any existing modal
|
||
document.getElementById('auth-modal')?.remove();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'auth-modal';
|
||
overlay.className = 'auth-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="auth-card fade-in">
|
||
<div class="auth-card__icon">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||
</svg>
|
||
</div>
|
||
<h2 class="auth-card__title">Authentication Required</h2>
|
||
<p class="auth-card__desc">This server requires a Bearer token.<br>Enter the value of <code>API_TOKEN</code> to continue.</p>
|
||
<form id="auth-form" class="auth-card__form">
|
||
<input
|
||
id="auth-token-input"
|
||
type="password"
|
||
class="input"
|
||
placeholder="Enter your API token…"
|
||
autocomplete="current-password"
|
||
spellcheck="false"
|
||
autofocus
|
||
/>
|
||
<button type="submit" class="btn btn--primary" style="width:100%">
|
||
<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||
Unlock
|
||
</button>
|
||
</form>
|
||
<p id="auth-error" class="auth-card__error" style="display:none">Incorrect token — try again.</p>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
const form = overlay.querySelector('#auth-form');
|
||
const input = overlay.querySelector('#auth-token-input');
|
||
const errorMsg = overlay.querySelector('#auth-error');
|
||
|
||
input.focus();
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const token = input.value.trim();
|
||
if (!token) return;
|
||
|
||
// Optimistically store and try a quick auth ping
|
||
setStoredToken(token);
|
||
try {
|
||
await api.status();
|
||
overlay.remove();
|
||
onSuccess();
|
||
} catch (err) {
|
||
// 401 means wrong token
|
||
setStoredToken('');
|
||
input.value = '';
|
||
input.focus();
|
||
errorMsg.style.display = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Document title badge ──────────────────────────────────────────────────
|
||
|
||
function updateTitle(instructions) {
|
||
const pending = (instructions || []).filter(i => i.status === 'pending').length;
|
||
document.title = pending > 0 ? `(${pending}) local-mcp` : 'local-mcp';
|
||
}
|
||
|
||
// ── Reconnecting indicator ────────────────────────────────────────────────
|
||
|
||
function initReconnectingIndicator() {
|
||
const serverLed = document.getElementById('led-server');
|
||
state.subscribe('sseReconnecting', (reconnecting) => {
|
||
if (!serverLed) return;
|
||
if (reconnecting) {
|
||
serverLed.className = 'led led--amber led--pulse';
|
||
serverLed.querySelector('.led__label').textContent = 'Reconnecting…';
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Startup data load ─────────────────────────────────────────────────────
|
||
|
||
async function loadInitialData() {
|
||
try {
|
||
const [instructions, status, config] = await Promise.all([
|
||
api.listInstructions('all'),
|
||
api.status(),
|
||
api.getConfig(),
|
||
]);
|
||
state.set('instructions', instructions.items);
|
||
state.set('status', status);
|
||
state.set('config', config);
|
||
state.set('serverOnline', true);
|
||
state.set('sseReconnecting', false);
|
||
} catch (err) {
|
||
console.error('Failed to load initial data:', err);
|
||
state.set('serverOnline', false);
|
||
}
|
||
}
|
||
|
||
// ── Periodic refresh (fallback for SSE gaps) ──────────────────────────────
|
||
|
||
function startPolling() {
|
||
setInterval(async () => {
|
||
try {
|
||
const s = await api.status();
|
||
state.set('status', s);
|
||
state.set('serverOnline', true);
|
||
} catch {
|
||
state.set('serverOnline', false);
|
||
}
|
||
}, 15_000);
|
||
|
||
// Refresh relative timestamps every 20 seconds
|
||
setInterval(() => { refreshTimestamps(); refreshStatusTimestamps(); }, 20_000);
|
||
}
|
||
|
||
// ── Subscribe to state changes ────────────────────────────────────────────
|
||
|
||
function initGlobalSubscriptions() {
|
||
state.subscribe('instructions', updateTitle);
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────
|
||
|
||
async function bootstrap() {
|
||
initTheme();
|
||
initReconnectingIndicator();
|
||
initStatus();
|
||
initConfig();
|
||
initInstructions();
|
||
initComposer();
|
||
initShortcuts();
|
||
initGlobalSubscriptions();
|
||
|
||
// Hook 401 handler so mid-session token expiry shows the modal again
|
||
setUnauthorizedHandler(() => {
|
||
showTokenModal(async () => {
|
||
await loadInitialData();
|
||
connectSSE();
|
||
});
|
||
});
|
||
|
||
// Check whether auth is required before doing any authenticated calls
|
||
const authInfo = await api.authCheck().catch(() => ({ auth_required: false }));
|
||
|
||
if (authInfo.auth_required && !getStoredToken()) {
|
||
// Show modal before loading data
|
||
showTokenModal(async () => {
|
||
await loadInitialData();
|
||
connectSSE();
|
||
startPolling();
|
||
});
|
||
} else {
|
||
// No auth, or token already stored — proceed normally
|
||
await loadInitialData();
|
||
connectSSE();
|
||
startPolling();
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', bootstrap);
|