1597 lines
66 KiB
HTML
1597 lines
66 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>AnviLLM Web</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
|
<style>
|
|
body { padding: 0; }
|
|
header {
|
|
background: var(--pico-primary-background);
|
|
padding: 0.75rem 2rem;
|
|
margin-bottom: 1rem;
|
|
position: relative;
|
|
}
|
|
header h1 {
|
|
margin: 0;
|
|
font-size: 1.25rem;
|
|
color: white;
|
|
}
|
|
main { padding: 0 2rem; }
|
|
.state-badge {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
}
|
|
.state-idle { background: #22c55e; color: white; }
|
|
.state-running { background: #3b82f6; color: white; }
|
|
.state-stopped { background: #6b7280; color: white; }
|
|
.state-error { background: #ef4444; color: white; }
|
|
.state-starting { background: #f97316; color: white; }
|
|
.state-exited { background: #9ca3af; color: white; }
|
|
|
|
table { margin-top: 1rem; }
|
|
table button { margin: 0 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.875rem; }
|
|
table td { vertical-align: middle; }
|
|
.actions-cell { white-space: nowrap; }
|
|
.session-id { font-family: monospace; font-size: 0.875rem; color: var(--pico-muted-color); }
|
|
|
|
.action-menu { position: relative; display: inline-block; }
|
|
.action-menu-btn { min-width: 2rem; }
|
|
.action-menu-content {
|
|
display: none;
|
|
position: absolute;
|
|
right: 0;
|
|
background: var(--pico-card-background-color);
|
|
border: 1px solid var(--pico-muted-border-color);
|
|
border-radius: 0.25rem;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
z-index: 100;
|
|
min-width: 120px;
|
|
}
|
|
.action-menu-content button {
|
|
display: block;
|
|
width: 100%;
|
|
text-align: left;
|
|
border: none;
|
|
border-radius: 0;
|
|
margin: 0;
|
|
}
|
|
.action-menu-content button:hover { background: var(--pico-secondary-background); }
|
|
.action-menu.open .action-menu-content { display: block; }
|
|
|
|
#prompt-area { min-height: 100px; }
|
|
.hidden { display: none; }
|
|
|
|
th.sortable { cursor: pointer; user-select: none; }
|
|
th.sortable:hover { background: rgba(0, 0, 0, 0.05); }
|
|
th.sortable::after { content: ' ⇅'; opacity: 0.3; }
|
|
th.sortable.asc::after { content: ' ↑'; opacity: 1; }
|
|
th.sortable.desc::after { content: ' ↓'; opacity: 1; }
|
|
|
|
#work-on-content tr[data-bead-id].selected td {
|
|
background: #3b82f6 !important;
|
|
color: white !important;
|
|
}
|
|
|
|
.inbox-btn {
|
|
position: absolute;
|
|
top: 50%;
|
|
right: 2rem;
|
|
transform: translateY(-50%);
|
|
background: transparent;
|
|
border: none;
|
|
font-size: 2rem;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
color: white;
|
|
line-height: 1;
|
|
}
|
|
.inbox-btn:hover {
|
|
opacity: 0.8;
|
|
}
|
|
.inbox-badge {
|
|
position: absolute;
|
|
top: -0.25rem;
|
|
left: -0.25rem;
|
|
background: #ef4444;
|
|
color: white;
|
|
border-radius: 50%;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
font-size: 0.625rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
}
|
|
.inbox-badge.hidden {
|
|
display: none;
|
|
}
|
|
.tasks-btn {
|
|
position: absolute;
|
|
top: 50%;
|
|
right: 5rem;
|
|
transform: translateY(-50%);
|
|
background: transparent;
|
|
border: none;
|
|
font-size: 2rem;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
color: white;
|
|
line-height: 1;
|
|
}
|
|
.tasks-btn:hover {
|
|
opacity: 0.8;
|
|
}
|
|
.bead-status {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
}
|
|
.bead-status-open { background: #3b82f6; color: white; }
|
|
.bead-status-in_progress { background: #f97316; color: white; }
|
|
.bead-status-closed { background: #22c55e; color: white; }
|
|
.bead-status-failed { background: #ef4444; color: white; }
|
|
.daemon-status {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 10rem;
|
|
transform: translateY(-50%);
|
|
font-size: 0.875rem;
|
|
color: white;
|
|
opacity: 0.8;
|
|
}
|
|
.daemon-status-indicator {
|
|
display: inline-block;
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
border-radius: 50%;
|
|
margin-right: 0.5rem;
|
|
}
|
|
.daemon-running { background: #22c55e; }
|
|
.daemon-stopped { background: #ef4444; }
|
|
|
|
/* ── Error banner (top-of-main dismissible notification) ── */
|
|
#error-banner {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
margin-bottom: 1rem;
|
|
background: #fef2f2;
|
|
border: 1px solid #fca5a5;
|
|
border-left: 4px solid #ef4444;
|
|
border-radius: 0.375rem;
|
|
color: #991b1b;
|
|
font-size: 0.875rem;
|
|
animation: fadeIn 0.15s ease;
|
|
}
|
|
#error-banner .error-icon { font-size: 1.1rem; flex-shrink: 0; margin-top: 0.05rem; }
|
|
#error-banner .error-body { flex: 1; }
|
|
#error-banner .error-title { font-weight: 700; margin-bottom: 0.2rem; }
|
|
#error-banner .error-detail { font-family: monospace; word-break: break-all; }
|
|
#error-banner .error-dismiss {
|
|
background: none;
|
|
border: none;
|
|
color: #991b1b;
|
|
font-size: 1.1rem;
|
|
cursor: pointer;
|
|
padding: 0 0.25rem;
|
|
opacity: 0.6;
|
|
flex-shrink: 0;
|
|
margin: 0;
|
|
width: auto;
|
|
line-height: 1;
|
|
}
|
|
#error-banner .error-dismiss:hover { opacity: 1; }
|
|
|
|
/* ── Inline error inside containers ── */
|
|
.error-container {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.6rem;
|
|
padding: 0.75rem 1rem;
|
|
margin-top: 1rem;
|
|
background: #fef2f2;
|
|
border: 1px solid #fca5a5;
|
|
border-left: 4px solid #ef4444;
|
|
border-radius: 0.375rem;
|
|
color: #991b1b;
|
|
font-size: 0.875rem;
|
|
}
|
|
.error-container .error-icon { flex-shrink: 0; font-size: 1rem; margin-top: 0.05rem; }
|
|
.error-container .error-detail { font-family: monospace; word-break: break-all; }
|
|
|
|
#error-banner.hidden { display: none; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
|
|
/* ── Approval / Review highlights in inbox ── */
|
|
tr.msg-needs-action td { background: rgba(245, 158, 11, 0.08) !important; }
|
|
.msg-type-tag {
|
|
display: inline-block;
|
|
padding: 0.1rem 0.35rem;
|
|
border-radius: 0.2rem;
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
vertical-align: middle;
|
|
margin-left: 0.3rem;
|
|
}
|
|
.msg-tag-approval { background: #f59e0b; color: white; }
|
|
.msg-tag-review { background: #8b5cf6; color: white; }
|
|
|
|
/* ── Approve / Reject buttons ── */
|
|
.btn-approve { background: #22c55e; border-color: #22c55e; color: white; }
|
|
.btn-approve:hover { background: #16a34a; border-color: #16a34a; }
|
|
.btn-reject { background: #ef4444; border-color: #ef4444; color: white; }
|
|
.btn-reject:hover { background: #dc2626; border-color: #dc2626; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1><a href="#" onclick="showTab('sessions'); return false;" style="color: white; text-decoration: none;">AnviLLM</a></h1>
|
|
<div class="daemon-status">
|
|
<span class="daemon-status-indicator daemon-stopped" id="daemon-indicator"></span>
|
|
<span id="daemon-text">Checking...</span>
|
|
</div>
|
|
<button class="tasks-btn" onclick="showTab('tasks')" title="Tasks">☑</button>
|
|
<button class="inbox-btn" onclick="showTab('inbox')" title="Inbox">
|
|
<span class="inbox-badge hidden" id="inbox-badge"></span>
|
|
✉
|
|
</button>
|
|
</header>
|
|
|
|
<main>
|
|
<!-- Global error banner -->
|
|
<div id="error-banner" class="hidden" role="alert">
|
|
<span class="error-icon">⛔</span>
|
|
<div class="error-body">
|
|
<div class="error-title" id="error-title">Error</div>
|
|
<div class="error-detail" id="error-detail"></div>
|
|
</div>
|
|
<button class="error-dismiss" onclick="dismissError()" aria-label="Dismiss">✕</button>
|
|
</div>
|
|
|
|
<section id="sessions-tab">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<h2>Sessions</h2>
|
|
<div>
|
|
<button onclick="openNewSessionDialog()">New Session</button>
|
|
<button onclick="loadSessions()" class="secondary">Refresh</button>
|
|
</div>
|
|
</div>
|
|
<input type="search" id="search-box" placeholder="Search sessions..." style="margin-top: 1rem;">
|
|
<div id="sessions-container"></div>
|
|
</section>
|
|
|
|
<section id="inbox-tab" class="hidden">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<h2>Inbox</h2>
|
|
<div>
|
|
<button onclick="showTab('archive')">Archive</button>
|
|
<button onclick="loadInbox()" class="secondary">Refresh</button>
|
|
<button onclick="showTab('sessions')" class="secondary">Back</button>
|
|
</div>
|
|
</div>
|
|
<div id="inbox-container" style="margin-top: 1rem;"></div>
|
|
</section>
|
|
|
|
<section id="archive-tab" class="hidden">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<h2>Archive</h2>
|
|
<div>
|
|
<button onclick="loadArchive()" class="secondary">Refresh</button>
|
|
<button onclick="showTab('inbox')" class="secondary">Back to Inbox</button>
|
|
</div>
|
|
</div>
|
|
<div id="archive-container" style="margin-top: 1rem;"></div>
|
|
</section>
|
|
|
|
<section id="tasks-tab" class="hidden">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<h2>Tasks</h2>
|
|
<div>
|
|
<button onclick="openNewBeadDialog()">New Task</button>
|
|
<button onclick="loadBeads()" class="secondary">Refresh</button>
|
|
<button onclick="showTab('sessions')" class="secondary">Back</button>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center;">
|
|
<span>Filter:</span>
|
|
<button id="filter-open" onclick="setBeadFilter('open')" class="contrast">Open</button>
|
|
<button id="filter-in_progress" onclick="setBeadFilter('in_progress')" class="secondary">In Progress</button>
|
|
<button id="filter-closed" onclick="setBeadFilter('closed')" class="secondary">Closed</button>
|
|
<button id="filter-all" onclick="setBeadFilter('all')" class="secondary">All</button>
|
|
</div>
|
|
<input type="search" id="tasks-search-box" placeholder="Search tasks..." style="margin-top: 1rem;">
|
|
<div id="tasks-container" style="margin-top: 1rem;"></div>
|
|
</section>
|
|
|
|
<dialog id="new-session-dialog">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev" onclick="closeNewSessionDialog()"></button>
|
|
<h3>New Session</h3>
|
|
</header>
|
|
<form id="new-session-form">
|
|
<label>
|
|
Backend
|
|
<select name="backend" required>
|
|
<option value="kiro-cli">Kiro</option>
|
|
<option value="claude">Claude</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Working Directory
|
|
<input type="text" name="cwd" placeholder="/path/to/project" required>
|
|
</label>
|
|
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
|
<button type="submit">Start Session</button>
|
|
<button type="button" onclick="closeNewSessionDialog()" class="secondary">Cancel</button>
|
|
</footer>
|
|
</form>
|
|
</article>
|
|
</dialog>
|
|
|
|
<dialog id="prompt-dialog">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev" onclick="closePromptDialog()"></button>
|
|
<h3>Send Prompt</h3>
|
|
</header>
|
|
<form id="prompt-form">
|
|
<label>
|
|
Prompt
|
|
<textarea id="prompt-area" name="prompt" required></textarea>
|
|
</label>
|
|
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
|
<button type="submit">Send</button>
|
|
<button type="button" onclick="closePromptDialog()" class="secondary">Cancel</button>
|
|
</footer>
|
|
</form>
|
|
</article>
|
|
</dialog>
|
|
|
|
<dialog id="context-dialog">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev" onclick="closeContextDialog()"></button>
|
|
<h3>Edit Context</h3>
|
|
</header>
|
|
<form id="context-form">
|
|
<label>
|
|
Context (prepended to all prompts)
|
|
<textarea id="context-area" name="context" rows="10"></textarea>
|
|
</label>
|
|
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
|
<button type="submit">Save</button>
|
|
<button type="button" onclick="closeContextDialog()" class="secondary">Cancel</button>
|
|
</footer>
|
|
</form>
|
|
</article>
|
|
</dialog>
|
|
|
|
<dialog id="alias-dialog">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev" onclick="closeAliasDialog()"></button>
|
|
<h3>Set Alias</h3>
|
|
</header>
|
|
<form id="alias-form">
|
|
<label>
|
|
Alias
|
|
<input type="text" id="alias-input" name="alias" required>
|
|
</label>
|
|
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
|
<button type="submit">Save</button>
|
|
<button type="button" onclick="closeAliasDialog()" class="secondary">Cancel</button>
|
|
</footer>
|
|
</form>
|
|
</article>
|
|
</dialog>
|
|
|
|
<dialog id="reply-dialog">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev" onclick="closeReplyDialog()"></button>
|
|
<h3>Reply</h3>
|
|
</header>
|
|
<form id="reply-form">
|
|
<input type="hidden" id="reply-to" name="to">
|
|
<input type="hidden" id="reply-type" name="type">
|
|
<label>
|
|
Subject
|
|
<input type="text" id="reply-subject" name="subject" readonly>
|
|
</label>
|
|
<label>
|
|
Message
|
|
<textarea id="reply-body" name="body" rows="10" required></textarea>
|
|
</label>
|
|
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
|
<button type="submit">Send</button>
|
|
<button type="button" onclick="closeReplyDialog()" class="secondary">Cancel</button>
|
|
</footer>
|
|
</form>
|
|
</article>
|
|
</dialog>
|
|
|
|
<dialog id="message-dialog">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev" onclick="closeMessageDialog()"></button>
|
|
<h3>Message</h3>
|
|
</header>
|
|
<div id="message-content" style="white-space: pre-wrap;"></div>
|
|
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
|
<button onclick="approveCurrentMessage()" id="message-approve-btn" class="btn-approve hidden">✓ Approve</button>
|
|
<button onclick="rejectCurrentMessage()" id="message-reject-btn" class="btn-reject hidden">✗ Reject</button>
|
|
<button onclick="replyToMessage()" id="message-reply-btn">Reply</button>
|
|
<button onclick="archiveCurrentMessage()" class="secondary">Archive</button>
|
|
<button onclick="closeMessageDialog()" class="secondary">Close</button>
|
|
</footer>
|
|
</article>
|
|
</dialog>
|
|
|
|
<dialog id="new-bead-dialog">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev" onclick="closeNewBeadDialog()"></button>
|
|
<h3>New Task</h3>
|
|
</header>
|
|
<form id="new-bead-form">
|
|
<label>
|
|
Title
|
|
<input type="text" name="title" required>
|
|
</label>
|
|
<label>
|
|
Description
|
|
<textarea name="description" rows="4"></textarea>
|
|
</label>
|
|
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
|
<button type="submit">Create</button>
|
|
<button type="button" onclick="closeNewBeadDialog()" class="secondary">Cancel</button>
|
|
</footer>
|
|
</form>
|
|
</article>
|
|
</dialog>
|
|
|
|
<dialog id="bead-detail-dialog">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev" onclick="closeBeadDetailDialog()"></button>
|
|
<h3 id="bead-detail-title">Task Details</h3>
|
|
</header>
|
|
<div id="bead-detail-content"></div>
|
|
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
|
<button onclick="closeBeadDetailDialog()" class="secondary">Close</button>
|
|
</footer>
|
|
</article>
|
|
</dialog>
|
|
|
|
<dialog id="work-on-dialog">
|
|
<article>
|
|
<header>
|
|
<button aria-label="Close" rel="prev" onclick="closeWorkOnDialog()"></button>
|
|
<h3>Select Task to Work On</h3>
|
|
</header>
|
|
<div id="work-on-content" style="min-height: 200px; max-height: 400px; overflow-y: auto;">
|
|
<p>Loading ready tasks...</p>
|
|
</div>
|
|
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
|
<button id="work-on-start-btn" onclick="startWorkOnBead()" disabled>Start</button>
|
|
<button onclick="closeWorkOnDialog()" class="secondary">Cancel</button>
|
|
</footer>
|
|
</article>
|
|
</dialog>
|
|
</main>
|
|
|
|
<script>
|
|
let currentSessionId = null;
|
|
let allSessions = [];
|
|
let sortColumn = 'id';
|
|
let sortAsc = true;
|
|
let previousStates = {};
|
|
|
|
// ── Error handling ─────────────────────────────────────────────
|
|
|
|
// Extract a meaningful error message from a non-OK Response.
|
|
async function apiError(res) {
|
|
let body = '';
|
|
try { body = (await res.text()).trim(); } catch (_) {}
|
|
const status = `${res.status} ${res.statusText}`.trim();
|
|
return body ? `${status}: ${body}` : status;
|
|
}
|
|
|
|
// Show the global dismissible error banner.
|
|
let errorDismissTimer = null;
|
|
function showError(detail, title = 'Error') {
|
|
clearTimeout(errorDismissTimer);
|
|
document.getElementById('error-title').textContent = title;
|
|
document.getElementById('error-detail').textContent = detail;
|
|
document.getElementById('error-banner').classList.remove('hidden');
|
|
// Auto-dismiss after 10 s
|
|
errorDismissTimer = setTimeout(dismissError, 10000);
|
|
}
|
|
|
|
function dismissError() {
|
|
clearTimeout(errorDismissTimer);
|
|
document.getElementById('error-banner').classList.add('hidden');
|
|
}
|
|
|
|
// Returns HTML for an inline error inside a container.
|
|
function errorHTML(detail) {
|
|
return `<div class="error-container" role="alert">
|
|
<span class="error-icon">⛔</span>
|
|
<span class="error-detail">${escapeHtml(detail)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Notifications ──────────────────────────────────────────────
|
|
|
|
if ('Notification' in window && Notification.permission === 'default') {
|
|
Notification.requestPermission();
|
|
}
|
|
|
|
function notifyStateChange(session, oldState, newState) {
|
|
if ('Notification' in window && Notification.permission === 'granted') {
|
|
const name = session.alias || session.id.substring(0, 8);
|
|
new Notification(`AnviLLM: ${name}`, {
|
|
body: `${oldState} → ${newState}`,
|
|
tag: session.id
|
|
});
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ── Action menu ────────────────────────────────────────────────
|
|
|
|
let openMenuSessionId = null;
|
|
|
|
function toggleMenu(btn) {
|
|
const menu = btn.closest('.action-menu');
|
|
const sessionId = btn.closest('tr').querySelector('[data-session-id]').dataset.sessionId;
|
|
document.querySelectorAll('.action-menu.open').forEach(m => {
|
|
if (m !== menu) m.classList.remove('open');
|
|
});
|
|
menu.classList.toggle('open');
|
|
openMenuSessionId = menu.classList.contains('open') ? sessionId : null;
|
|
}
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.action-menu')) {
|
|
document.querySelectorAll('.action-menu.open').forEach(m => m.classList.remove('open'));
|
|
openMenuSessionId = null;
|
|
}
|
|
});
|
|
|
|
// ── Sessions ───────────────────────────────────────────────────
|
|
|
|
function filterSessions() {
|
|
const query = document.getElementById('search-box').value.toLowerCase();
|
|
const filtered = allSessions.filter(s => {
|
|
const searchText = [
|
|
s.alias || '',
|
|
s.id || '',
|
|
s.state || '',
|
|
s.backend || '',
|
|
s.cwd || ''
|
|
].join(' ').toLowerCase();
|
|
return searchText.includes(query);
|
|
});
|
|
renderSessions(filtered);
|
|
if (openMenuSessionId) {
|
|
const btn = document.querySelector(`[data-session-id="${openMenuSessionId}"]`)?.closest('tr')?.querySelector('.action-menu-btn');
|
|
if (btn) btn.closest('.action-menu').classList.add('open');
|
|
}
|
|
}
|
|
|
|
function renderSessions(sessions) {
|
|
const container = document.getElementById('sessions-container');
|
|
if (sessions.length === 0) {
|
|
container.innerHTML = '<p>No sessions found</p>';
|
|
return;
|
|
}
|
|
|
|
sessions.sort((a, b) => {
|
|
let valA, valB;
|
|
switch (sortColumn) {
|
|
case 'id': valA = a.id; valB = b.id; break;
|
|
case 'alias': valA = (a.alias || '').toLowerCase(); valB = (b.alias || '').toLowerCase(); break;
|
|
case 'state': valA = a.state; valB = b.state; break;
|
|
case 'backend': valA = a.backend || ''; valB = b.backend || ''; break;
|
|
case 'cwd': valA = a.cwd; valB = b.cwd; break;
|
|
default: valA = ''; valB = '';
|
|
}
|
|
const cmp = String(valA).localeCompare(String(valB));
|
|
return sortAsc ? cmp : -cmp;
|
|
});
|
|
|
|
const sortClass = col => `sortable${sortColumn === col ? (sortAsc ? ' asc' : ' desc') : ''}`;
|
|
const table = `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="${sortClass('id')}" data-sort="id">ID</th>
|
|
<th class="${sortClass('alias')}" data-sort="alias">Alias</th>
|
|
<th class="${sortClass('state')}" data-sort="state">State</th>
|
|
<th class="${sortClass('backend')}" data-sort="backend">Backend</th>
|
|
<th class="${sortClass('cwd')}" data-sort="cwd">Working Directory</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${sessions.map(s => {
|
|
const alias = escapeHtml(s.alias || '-');
|
|
const backend = escapeHtml(s.backend || 'unknown');
|
|
const cwd = escapeHtml(s.cwd);
|
|
const state = escapeHtml(s.state);
|
|
const id = escapeHtml(s.id);
|
|
|
|
return `
|
|
<tr>
|
|
<td><span class="session-id">${id}</span></td>
|
|
<td><strong>${alias}</strong></td>
|
|
<td><span class="state-badge state-${state}">${state}</span></td>
|
|
<td>${backend}</td>
|
|
<td><small>${cwd}</small></td>
|
|
<td class="actions-cell">
|
|
<button data-session-id="${id}" data-action="prompt">Prompt</button>
|
|
<button data-session-id="${id}" data-action="work-on">Work On</button>
|
|
<button data-session-id="${id}" data-action="attach">Attach</button>
|
|
<div class="action-menu">
|
|
<button class="action-menu-btn secondary" onclick="toggleMenu(this)">⋯</button>
|
|
<div class="action-menu-content">
|
|
<button data-session-id="${id}" data-action="context">Context</button>
|
|
<button data-session-id="${id}" data-action="alias">Alias</button>
|
|
<button data-session-id="${id}" data-action="stop" ${s.state === 'stopped' ? 'disabled' : ''}>Stop</button>
|
|
<button data-session-id="${id}" data-action="restart">Restart</button>
|
|
<button data-session-id="${id}" data-action="kill" class="contrast">Kill</button>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
|
|
container.innerHTML = table;
|
|
}
|
|
|
|
async function loadSessions() {
|
|
try {
|
|
const res = await fetch('/api/sessions');
|
|
if (!res.ok) {
|
|
const msg = await apiError(res);
|
|
document.getElementById('sessions-container').innerHTML = errorHTML('Failed to load sessions: ' + msg);
|
|
return;
|
|
}
|
|
const sessions = await res.json();
|
|
|
|
for (const s of sessions) {
|
|
const oldState = previousStates[s.id];
|
|
if (oldState && oldState !== s.state) {
|
|
notifyStateChange(s, oldState, s.state);
|
|
}
|
|
previousStates[s.id] = s.state;
|
|
}
|
|
|
|
allSessions = sessions;
|
|
filterSessions();
|
|
} catch (err) {
|
|
document.getElementById('sessions-container').innerHTML = errorHTML('Failed to connect to server: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function openNewSessionDialog() {
|
|
document.getElementById('new-session-dialog').showModal();
|
|
}
|
|
|
|
function closeNewSessionDialog() {
|
|
document.getElementById('new-session-dialog').close();
|
|
document.getElementById('new-session-form').reset();
|
|
}
|
|
|
|
document.getElementById('new-session-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const data = new FormData(form);
|
|
|
|
try {
|
|
const res = await fetch('/api/sessions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
backend: data.get('backend'),
|
|
cwd: data.get('cwd')
|
|
})
|
|
});
|
|
|
|
if (res.ok) {
|
|
form.reset();
|
|
closeNewSessionDialog();
|
|
setTimeout(loadSessions, 500);
|
|
} else {
|
|
showError(await apiError(res), 'Failed to create session');
|
|
}
|
|
} catch (err) {
|
|
showError(err.message, 'Failed to create session');
|
|
}
|
|
});
|
|
|
|
function openPromptDialog(id) {
|
|
currentSessionId = id;
|
|
document.getElementById('prompt-dialog').showModal();
|
|
document.getElementById('prompt-area').focus();
|
|
}
|
|
|
|
function closePromptDialog() {
|
|
document.getElementById('prompt-dialog').close();
|
|
document.getElementById('prompt-form').reset();
|
|
}
|
|
|
|
document.getElementById('prompt-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
let prompt = document.getElementById('prompt-area').value;
|
|
|
|
const trimmedPrompt = prompt.trim();
|
|
if (isBeadID(trimmedPrompt)) {
|
|
prompt = `Load the beads skill, and work on bead ${trimmedPrompt}.`;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/session/${currentSessionId}/prompt`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ prompt })
|
|
});
|
|
|
|
if (res.ok) {
|
|
closePromptDialog();
|
|
loadSessions();
|
|
} else {
|
|
showError(await apiError(res), 'Failed to send prompt');
|
|
}
|
|
} catch (err) {
|
|
showError(err.message, 'Failed to send prompt');
|
|
}
|
|
});
|
|
|
|
function isBeadID(text) {
|
|
return /^[a-zA-Z]+-[a-z0-9]+(\.[0-9]+)*$/.test(text);
|
|
}
|
|
|
|
async function openContextDialog(id) {
|
|
currentSessionId = id;
|
|
try {
|
|
const res = await fetch(`/api/session/${id}/context`);
|
|
if (!res.ok) {
|
|
showError(await apiError(res), 'Failed to load context');
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
document.getElementById('context-area').value = data.content || '';
|
|
document.getElementById('context-dialog').showModal();
|
|
} catch (err) {
|
|
showError(err.message, 'Failed to load context');
|
|
}
|
|
}
|
|
|
|
function closeContextDialog() {
|
|
document.getElementById('context-dialog').close();
|
|
}
|
|
|
|
document.getElementById('context-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const content = document.getElementById('context-area').value;
|
|
|
|
try {
|
|
const res = await fetch(`/api/session/${currentSessionId}/context`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content })
|
|
});
|
|
|
|
if (res.ok) {
|
|
closeContextDialog();
|
|
} else {
|
|
showError(await apiError(res), 'Failed to save context');
|
|
}
|
|
} catch (err) {
|
|
showError(err.message, 'Failed to save context');
|
|
}
|
|
});
|
|
|
|
async function openAliasDialog(id) {
|
|
currentSessionId = id;
|
|
try {
|
|
const res = await fetch(`/api/session/${id}/alias`);
|
|
if (!res.ok) {
|
|
showError(await apiError(res), 'Failed to load alias');
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
document.getElementById('alias-input').value = data.content || '';
|
|
document.getElementById('alias-dialog').showModal();
|
|
} catch (err) {
|
|
showError(err.message, 'Failed to load alias');
|
|
}
|
|
}
|
|
|
|
function closeAliasDialog() {
|
|
document.getElementById('alias-dialog').close();
|
|
}
|
|
|
|
document.getElementById('alias-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const content = document.getElementById('alias-input').value;
|
|
|
|
try {
|
|
const res = await fetch(`/api/session/${currentSessionId}/alias`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content })
|
|
});
|
|
|
|
if (res.ok) {
|
|
closeAliasDialog();
|
|
loadSessions();
|
|
} else {
|
|
showError(await apiError(res), 'Failed to save alias');
|
|
}
|
|
} catch (err) {
|
|
showError(err.message, 'Failed to save alias');
|
|
}
|
|
});
|
|
|
|
async function controlSession(id, cmd) {
|
|
try {
|
|
const res = await fetch(`/api/session/${id}/ctl`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: cmd })
|
|
});
|
|
|
|
if (res.ok) {
|
|
setTimeout(loadSessions, 500);
|
|
} else {
|
|
showError(await apiError(res), `Failed to ${cmd} session`);
|
|
}
|
|
} catch (err) {
|
|
showError(err.message, `Failed to ${cmd} session`);
|
|
}
|
|
}
|
|
|
|
// Load sessions on page load
|
|
loadSessions();
|
|
|
|
async function attachSession(id) {
|
|
try {
|
|
const res = await fetch(`/api/session/${id}/ctl`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: 'attach' })
|
|
});
|
|
if (!res.ok) {
|
|
showError(await apiError(res), 'Failed to attach session');
|
|
}
|
|
} catch (err) {
|
|
showError(err.message, 'Failed to attach session');
|
|
}
|
|
}
|
|
|
|
// Auto-refresh every 5 seconds
|
|
setInterval(() => {
|
|
loadSessions();
|
|
updateInboxBadge();
|
|
if (!document.getElementById('inbox-tab').classList.contains('hidden')) {
|
|
loadInbox();
|
|
}
|
|
}, 5000);
|
|
|
|
// Initial load
|
|
updateInboxBadge();
|
|
|
|
// Search box filtering
|
|
document.getElementById('search-box').addEventListener('input', filterSessions);
|
|
|
|
// Column sorting
|
|
document.addEventListener('click', (e) => {
|
|
const th = e.target.closest('th[data-sort]');
|
|
if (th) {
|
|
const col = th.dataset.sort;
|
|
if (sortColumn === col) sortAsc = !sortAsc;
|
|
else { sortColumn = col; sortAsc = true; }
|
|
filterSessions();
|
|
}
|
|
});
|
|
|
|
// Event delegation for session actions
|
|
document.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button[data-session-id]');
|
|
if (!btn || btn.disabled) return;
|
|
|
|
const id = btn.dataset.sessionId;
|
|
const action = btn.dataset.action;
|
|
|
|
switch (action) {
|
|
case 'attach': attachSession(id); break;
|
|
case 'prompt': openPromptDialog(id); break;
|
|
case 'work-on': openWorkOnDialog(id); break;
|
|
case 'context': openContextDialog(id); break;
|
|
case 'alias': openAliasDialog(id); break;
|
|
case 'stop': controlSession(id, 'stop'); break;
|
|
case 'restart': controlSession(id, 'restart'); break;
|
|
case 'kill': controlSession(id, 'kill'); break;
|
|
}
|
|
});
|
|
|
|
// ── Work On dialog ─────────────────────────────────────────────
|
|
|
|
let selectedBeadId = null;
|
|
|
|
async function openWorkOnDialog(sessionId) {
|
|
currentSessionId = sessionId;
|
|
selectedBeadId = null;
|
|
document.getElementById('work-on-start-btn').disabled = true;
|
|
document.getElementById('work-on-dialog').showModal();
|
|
|
|
try {
|
|
const res = await fetch('/api/beads/ready');
|
|
if (!res.ok) {
|
|
document.getElementById('work-on-content').innerHTML = errorHTML('Failed to load ready tasks: ' + await apiError(res));
|
|
return;
|
|
}
|
|
const readyBeads = await res.json();
|
|
renderWorkOnBeads(readyBeads);
|
|
} catch (error) {
|
|
document.getElementById('work-on-content').innerHTML = errorHTML('Failed to load ready tasks: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function renderWorkOnBeads(beads) {
|
|
const container = document.getElementById('work-on-content');
|
|
|
|
if (beads.length === 0) {
|
|
container.innerHTML = '<p>No ready tasks available</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table><thead><tr><th>ID</th><th>Title</th><th>Priority</th></tr></thead><tbody>';
|
|
|
|
for (const bead of beads) {
|
|
const description = bead.description || '';
|
|
html += `<tr onclick="selectBeadForWork('${bead.id}')" style="cursor: pointer;" data-bead-id="${bead.id}">
|
|
<td><small>${escapeHtml(bead.id)}</small></td>
|
|
<td title="${escapeHtml(description)}">${escapeHtml(bead.title)}</td>
|
|
<td>${bead.priority || '-'}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function selectBeadForWork(beadId) {
|
|
selectedBeadId = beadId;
|
|
|
|
document.querySelectorAll('#work-on-content tr[data-bead-id]').forEach(row => {
|
|
if (row.dataset.beadId === beadId) {
|
|
row.classList.add('selected');
|
|
} else {
|
|
row.classList.remove('selected');
|
|
}
|
|
});
|
|
|
|
document.getElementById('work-on-start-btn').disabled = false;
|
|
}
|
|
|
|
async function startWorkOnBead() {
|
|
if (!selectedBeadId || !currentSessionId) return;
|
|
|
|
const prompt = `Load the beads skill, and work on bead ${selectedBeadId}.`;
|
|
|
|
try {
|
|
const res = await fetch(`/api/session/${currentSessionId}/prompt`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ prompt })
|
|
});
|
|
|
|
if (res.ok) {
|
|
closeWorkOnDialog();
|
|
loadSessions();
|
|
} else {
|
|
showError(await apiError(res), 'Failed to send prompt');
|
|
}
|
|
} catch (err) {
|
|
showError(err.message, 'Failed to send prompt');
|
|
}
|
|
}
|
|
|
|
function closeWorkOnDialog() {
|
|
document.getElementById('work-on-dialog').close();
|
|
selectedBeadId = null;
|
|
document.getElementById('work-on-start-btn').disabled = true;
|
|
}
|
|
|
|
// ── Tab switching ──────────────────────────────────────────────
|
|
|
|
function showTab(tab) {
|
|
document.getElementById('sessions-tab').classList.toggle('hidden', tab !== 'sessions');
|
|
document.getElementById('inbox-tab').classList.toggle('hidden', tab !== 'inbox');
|
|
document.getElementById('archive-tab').classList.toggle('hidden', tab !== 'archive');
|
|
document.getElementById('tasks-tab').classList.toggle('hidden', tab !== 'tasks');
|
|
|
|
if (tab === 'inbox') {
|
|
loadInbox();
|
|
} else if (tab === 'archive') {
|
|
loadArchive();
|
|
} else if (tab === 'tasks') {
|
|
document.querySelectorAll('[id^="filter-"]').forEach(btn => {
|
|
btn.className = 'secondary';
|
|
});
|
|
document.getElementById(`filter-${beadStatusFilter}`).className = 'contrast';
|
|
loadBeads();
|
|
}
|
|
}
|
|
|
|
// ── Inbox ──────────────────────────────────────────────────────
|
|
|
|
let currentMessage = null;
|
|
let pendingArchiveId = null; // set when reject opens reply dialog, cleared on send
|
|
let inboxMessages = [];
|
|
let archiveMessages = [];
|
|
|
|
async function updateInboxBadge() {
|
|
try {
|
|
const response = await fetch('/api/inbox/count');
|
|
if (!response.ok) return; // silent — badge is non-critical
|
|
const data = await response.json();
|
|
const badge = document.getElementById('inbox-badge');
|
|
if (data.count > 0) {
|
|
badge.textContent = data.count > 9 ? '9+' : data.count;
|
|
badge.classList.remove('hidden');
|
|
} else {
|
|
badge.classList.add('hidden');
|
|
}
|
|
} catch (_) {
|
|
// Non-critical; swallow silently
|
|
}
|
|
}
|
|
|
|
async function loadInbox() {
|
|
try {
|
|
const response = await fetch('/api/inbox');
|
|
if (!response.ok) {
|
|
document.getElementById('inbox-container').innerHTML = errorHTML('Failed to load inbox: ' + await apiError(response));
|
|
return;
|
|
}
|
|
const messages = await response.json();
|
|
renderInbox(messages);
|
|
updateInboxBadge();
|
|
} catch (error) {
|
|
document.getElementById('inbox-container').innerHTML = errorHTML('Failed to load inbox: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function renderInbox(messages) {
|
|
messages = messages || [];
|
|
messages.sort((a, b) => b.timestamp - a.timestamp);
|
|
inboxMessages = messages;
|
|
|
|
const container = document.getElementById('inbox-container');
|
|
if (messages.length === 0) {
|
|
container.innerHTML = '<p>No messages</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table><thead><tr><th>Date</th><th>From</th><th>Type</th><th>Subject</th><th>Actions</th></tr></thead><tbody>';
|
|
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const msg = messages[i];
|
|
const date = new Date(msg.timestamp * 1000).toLocaleString();
|
|
const needsAction = msg.type === 'APPROVAL_REQUEST' || msg.type === 'REVIEW_REQUEST';
|
|
const rowClass = needsAction ? ' class="msg-needs-action"' : '';
|
|
let typeHtml = escapeHtml(msg.type);
|
|
if (msg.type === 'APPROVAL_REQUEST') {
|
|
typeHtml += ' <span class="msg-type-tag msg-tag-approval">Approval</span>';
|
|
} else if (msg.type === 'REVIEW_REQUEST') {
|
|
typeHtml += ' <span class="msg-type-tag msg-tag-review">Review</span>';
|
|
}
|
|
html += `<tr${rowClass}>
|
|
<td>${escapeHtml(date)}</td>
|
|
<td>${escapeHtml(msg.from || '-')}</td>
|
|
<td>${typeHtml}</td>
|
|
<td>${escapeHtml(msg.subject)}</td>
|
|
<td>
|
|
<button onclick='viewInboxMessage(${i})'>View</button>
|
|
<button onclick='archiveMessageById("${escapeHtml(msg.id)}")' class="secondary">Archive</button>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
async function loadArchive() {
|
|
try {
|
|
const response = await fetch('/api/archive');
|
|
if (!response.ok) {
|
|
document.getElementById('archive-container').innerHTML = errorHTML('Failed to load archive: ' + await apiError(response));
|
|
return;
|
|
}
|
|
const messages = await response.json();
|
|
renderArchive(messages);
|
|
} catch (error) {
|
|
document.getElementById('archive-container').innerHTML = errorHTML('Failed to load archive: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function renderArchive(messages) {
|
|
messages = messages || [];
|
|
messages.sort((a, b) => b.timestamp - a.timestamp);
|
|
archiveMessages = messages;
|
|
|
|
const container = document.getElementById('archive-container');
|
|
if (messages.length === 0) {
|
|
container.innerHTML = '<p>No archived messages</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table><thead><tr><th>Date</th><th>From</th><th>Type</th><th>Subject</th><th>Actions</th></tr></thead><tbody>';
|
|
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const msg = messages[i];
|
|
const date = new Date(msg.timestamp * 1000).toLocaleString();
|
|
html += `<tr>
|
|
<td>${escapeHtml(date)}</td>
|
|
<td>${escapeHtml(msg.from || '-')}</td>
|
|
<td>${escapeHtml(msg.type)}</td>
|
|
<td>${escapeHtml(msg.subject)}</td>
|
|
<td>
|
|
<button onclick='viewArchiveMessage(${i})'>View</button>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function viewMessage(msg) {
|
|
currentMessage = msg;
|
|
const date = new Date(msg.timestamp * 1000).toLocaleString();
|
|
const content = `From: ${msg.from || '-'}
|
|
To: ${msg.to || '-'}
|
|
Type: ${msg.type}
|
|
Subject: ${msg.subject}
|
|
Date: ${date}
|
|
|
|
${msg.body}`;
|
|
|
|
document.getElementById('message-content').textContent = content;
|
|
|
|
// Show Approve / Reject buttons only for actionable message types
|
|
const needsApproval = msg.type === 'APPROVAL_REQUEST' || msg.type === 'REVIEW_REQUEST';
|
|
document.getElementById('message-approve-btn').classList.toggle('hidden', !needsApproval);
|
|
document.getElementById('message-reject-btn').classList.toggle('hidden', !needsApproval);
|
|
|
|
document.getElementById('message-dialog').showModal();
|
|
}
|
|
|
|
function viewInboxMessage(index) {
|
|
if (index >= 0 && index < inboxMessages.length) {
|
|
viewMessage(inboxMessages[index]);
|
|
}
|
|
}
|
|
|
|
function viewArchiveMessage(index) {
|
|
if (index >= 0 && index < archiveMessages.length) {
|
|
viewMessage(archiveMessages[index]);
|
|
}
|
|
}
|
|
|
|
function closeMessageDialog() {
|
|
document.getElementById('message-dialog').close();
|
|
currentMessage = null;
|
|
}
|
|
|
|
function getReplyType(msgType) {
|
|
const replyMap = {
|
|
'QUERY_REQUEST': 'QUERY_RESPONSE',
|
|
'REVIEW_REQUEST': 'REVIEW_RESPONSE',
|
|
'APPROVAL_REQUEST': 'APPROVAL_RESPONSE'
|
|
};
|
|
return replyMap[msgType] || 'PROMPT_REQUEST';
|
|
}
|
|
|
|
function replyToMessage() {
|
|
if (!currentMessage) return;
|
|
|
|
const replyType = getReplyType(currentMessage.type);
|
|
const replySubject = `Re: ${currentMessage.subject}`;
|
|
|
|
document.getElementById('reply-to').value = currentMessage.from;
|
|
document.getElementById('reply-type').value = replyType;
|
|
document.getElementById('reply-subject').value = replySubject;
|
|
document.getElementById('reply-body').value = '';
|
|
|
|
closeMessageDialog();
|
|
document.getElementById('reply-dialog').showModal();
|
|
}
|
|
|
|
async function approveCurrentMessage() {
|
|
if (!currentMessage) return;
|
|
const replyType = getReplyType(currentMessage.type);
|
|
const data = {
|
|
to: currentMessage.from,
|
|
type: replyType,
|
|
subject: `Re: ${currentMessage.subject}`,
|
|
body: 'Approved'
|
|
};
|
|
try {
|
|
const response = await fetch('/api/inbox/reply', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (response.ok) {
|
|
const msgId = currentMessage.id;
|
|
closeMessageDialog();
|
|
await archiveMessage(msgId); // auto-archive; archiveMessage calls loadInbox
|
|
} else {
|
|
showError(await apiError(response), 'Failed to send approval');
|
|
}
|
|
} catch (error) {
|
|
showError(error.message, 'Failed to send approval');
|
|
}
|
|
}
|
|
|
|
function rejectCurrentMessage() {
|
|
if (!currentMessage) return;
|
|
pendingArchiveId = currentMessage.id; // auto-archive when reply is sent
|
|
const replyType = getReplyType(currentMessage.type);
|
|
document.getElementById('reply-to').value = currentMessage.from;
|
|
document.getElementById('reply-type').value = replyType;
|
|
document.getElementById('reply-subject').value = `Re: ${currentMessage.subject}`;
|
|
document.getElementById('reply-body').value = 'Rejected: ';
|
|
closeMessageDialog();
|
|
document.getElementById('reply-dialog').showModal();
|
|
const textarea = document.getElementById('reply-body');
|
|
textarea.focus();
|
|
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
}
|
|
|
|
function closeReplyDialog() {
|
|
document.getElementById('reply-dialog').close();
|
|
}
|
|
|
|
async function archiveMessage(msgId) {
|
|
try {
|
|
const response = await fetch('/api/inbox/archive', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename: msgId })
|
|
});
|
|
|
|
if (response.ok) {
|
|
await loadInbox();
|
|
} else {
|
|
showError(await apiError(response), 'Failed to archive message');
|
|
}
|
|
} catch (error) {
|
|
showError(error.message, 'Failed to archive message');
|
|
}
|
|
}
|
|
|
|
async function archiveMessageById(msgId) {
|
|
await archiveMessage(msgId);
|
|
}
|
|
|
|
function archiveCurrentMessage() {
|
|
if (!currentMessage) return;
|
|
archiveMessage(currentMessage.id);
|
|
closeMessageDialog();
|
|
}
|
|
|
|
document.getElementById('reply-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.target);
|
|
const data = {
|
|
to: formData.get('to'),
|
|
type: formData.get('type'),
|
|
subject: formData.get('subject'),
|
|
body: formData.get('body')
|
|
};
|
|
|
|
try {
|
|
const response = await fetch('/api/inbox/reply', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
if (response.ok) {
|
|
closeReplyDialog();
|
|
if (pendingArchiveId) {
|
|
const idToArchive = pendingArchiveId;
|
|
pendingArchiveId = null;
|
|
await archiveMessage(idToArchive); // auto-archive; calls loadInbox
|
|
} else {
|
|
loadInbox();
|
|
}
|
|
} else {
|
|
showError(await apiError(response), 'Failed to send reply');
|
|
}
|
|
} catch (error) {
|
|
showError(error.message, 'Failed to send reply');
|
|
}
|
|
});
|
|
|
|
// ── Tasks / Beads ──────────────────────────────────────────────
|
|
|
|
let allBeads = [];
|
|
let currentBead = null;
|
|
let beadStatusFilter = 'open';
|
|
|
|
async function loadBeads() {
|
|
try {
|
|
const response = await fetch('/api/beads');
|
|
if (!response.ok) {
|
|
document.getElementById('tasks-container').innerHTML = errorHTML('Failed to load tasks: ' + await apiError(response));
|
|
return;
|
|
}
|
|
const beads = await response.json();
|
|
allBeads = beads;
|
|
filterBeads();
|
|
} catch (error) {
|
|
document.getElementById('tasks-container').innerHTML = errorHTML('Failed to load tasks: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function setBeadFilter(status) {
|
|
beadStatusFilter = status;
|
|
|
|
document.querySelectorAll('[id^="filter-"]').forEach(btn => {
|
|
btn.className = 'secondary';
|
|
});
|
|
document.getElementById(`filter-${status}`).className = 'contrast';
|
|
|
|
filterBeads();
|
|
}
|
|
|
|
function filterBeads() {
|
|
const query = document.getElementById('tasks-search-box').value.toLowerCase();
|
|
const filtered = allBeads.filter(b => {
|
|
if (beadStatusFilter !== 'all' && b.status !== beadStatusFilter) {
|
|
return false;
|
|
}
|
|
|
|
const searchText = [
|
|
b.id || '',
|
|
b.title || '',
|
|
b.description || '',
|
|
b.status || '',
|
|
b.assignee || ''
|
|
].join(' ').toLowerCase();
|
|
return searchText.includes(query);
|
|
});
|
|
renderBeads(filtered);
|
|
}
|
|
|
|
function renderBeads(beads) {
|
|
const container = document.getElementById('tasks-container');
|
|
if (beads.length === 0) {
|
|
container.innerHTML = '<p>No tasks found</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table><thead><tr><th>ID</th><th>Title</th><th>Status</th><th>Priority</th><th>Assignee</th><th>Actions</th></tr></thead><tbody>';
|
|
|
|
for (const bead of beads) {
|
|
const statusClass = `bead-status-${bead.status}`;
|
|
html += `<tr>
|
|
<td><small>${escapeHtml(bead.id)}</small></td>
|
|
<td>${escapeHtml(bead.title)}</td>
|
|
<td><span class="bead-status ${statusClass}">${escapeHtml(bead.status)}</span></td>
|
|
<td>${bead.priority || '-'}</td>
|
|
<td>${escapeHtml(bead.assignee || '-')}</td>
|
|
<td>
|
|
<button onclick='viewBead("${bead.id}")'>View</button>
|
|
${bead.status === 'open' ? `<button onclick='claimBead("${bead.id}")'>Claim</button>` : ''}
|
|
${bead.status === 'in_progress' ? `<button onclick='completeBead("${bead.id}")' class="secondary">Complete</button>` : ''}
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function openNewBeadDialog() {
|
|
document.getElementById('new-bead-dialog').showModal();
|
|
}
|
|
|
|
function closeNewBeadDialog() {
|
|
document.getElementById('new-bead-dialog').close();
|
|
document.getElementById('new-bead-form').reset();
|
|
}
|
|
|
|
document.getElementById('new-bead-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.target);
|
|
const title = formData.get('title');
|
|
const description = formData.get('description');
|
|
|
|
const command = `new '${title.replace(/'/g, "\\'")}' '${description.replace(/'/g, "\\'")}'`;
|
|
|
|
try {
|
|
const response = await fetch('/api/beads/ctl', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command })
|
|
});
|
|
|
|
if (response.ok) {
|
|
closeNewBeadDialog();
|
|
setTimeout(loadBeads, 500);
|
|
} else {
|
|
showError(await apiError(response), 'Failed to create task');
|
|
}
|
|
} catch (error) {
|
|
showError(error.message, 'Failed to create task');
|
|
}
|
|
});
|
|
|
|
async function viewBead(id) {
|
|
try {
|
|
const response = await fetch(`/api/beads/${id}`);
|
|
if (!response.ok) {
|
|
showError(await apiError(response), 'Failed to load task details');
|
|
return;
|
|
}
|
|
const bead = await response.json();
|
|
currentBead = bead;
|
|
|
|
const statusClass = `bead-status-${bead.status}`;
|
|
let content = `<p><strong>ID:</strong> ${escapeHtml(bead.id)}</p>`;
|
|
content += `<p><strong>Status:</strong> <span class="bead-status ${statusClass}">${escapeHtml(bead.status)}</span></p>`;
|
|
content += `<p><strong>Priority:</strong> ${bead.priority || '-'}</p>`;
|
|
content += `<p><strong>Type:</strong> ${escapeHtml(bead.issue_type || '-')}</p>`;
|
|
content += `<p><strong>Assignee:</strong> ${escapeHtml(bead.assignee || '-')}</p>`;
|
|
content += `<p><strong>Created:</strong> ${new Date(bead.created_at).toLocaleString()}</p>`;
|
|
content += `<p><strong>Updated:</strong> ${new Date(bead.updated_at).toLocaleString()}</p>`;
|
|
content += `<p><strong>Description:</strong></p><p>${escapeHtml(bead.description || '')}</p>`;
|
|
|
|
if (bead.blockers && bead.blockers.length > 0) {
|
|
content += `<p><strong>Blockers:</strong> ${bead.blockers.map(b => escapeHtml(b)).join(', ')}</p>`;
|
|
}
|
|
|
|
const hasApproval = bead.labels && bead.labels.includes('requires_approval');
|
|
const hasReview = bead.labels && bead.labels.includes('requires_review');
|
|
content += `<p><strong>Requires Approval:</strong> <span style="color:${hasApproval ? 'green' : 'inherit'}">${hasApproval ? 'Yes' : 'No'}</span>
|
|
<button class="outline" style="margin-left:0.5rem; padding:0.1rem 0.5rem; font-size:0.8rem;"
|
|
onclick="toggleBeadFlag('${escapeHtml(bead.id)}', 'requires_approval', ${hasApproval})">${hasApproval ? 'Unset' : 'Set'}</button></p>`;
|
|
content += `<p><strong>Requires Review:</strong> <span style="color:${hasReview ? 'green' : 'inherit'}">${hasReview ? 'Yes' : 'No'}</span>
|
|
<button class="outline" style="margin-left:0.5rem; padding:0.1rem 0.5rem; font-size:0.8rem;"
|
|
onclick="toggleBeadFlag('${escapeHtml(bead.id)}', 'requires_review', ${hasReview})">${hasReview ? 'Unset' : 'Set'}</button></p>`;
|
|
|
|
document.getElementById('bead-detail-title').textContent = bead.title;
|
|
document.getElementById('bead-detail-content').innerHTML = content;
|
|
document.getElementById('bead-detail-dialog').showModal();
|
|
} catch (error) {
|
|
showError(error.message, 'Failed to load task details');
|
|
}
|
|
}
|
|
|
|
function closeBeadDetailDialog() {
|
|
document.getElementById('bead-detail-dialog').close();
|
|
currentBead = null;
|
|
}
|
|
|
|
async function toggleBeadFlag(id, flag, currentlySet) {
|
|
const cmd = currentlySet ? `unlabel ${id} ${flag}` : `label ${id} ${flag}`;
|
|
try {
|
|
const response = await fetch('/api/beads/ctl', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: cmd })
|
|
});
|
|
if (response.ok) {
|
|
// Re-open the detail dialog with refreshed data
|
|
document.getElementById('bead-detail-dialog').close();
|
|
await viewBead(id);
|
|
} else {
|
|
showError(await apiError(response), `Failed to toggle ${flag}`);
|
|
}
|
|
} catch (error) {
|
|
showError(error.message, `Failed to toggle ${flag}`);
|
|
}
|
|
}
|
|
|
|
async function claimBead(id) {
|
|
try {
|
|
const response = await fetch('/api/beads/ctl', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: `claim ${id}` })
|
|
});
|
|
|
|
if (response.ok) {
|
|
setTimeout(loadBeads, 500);
|
|
} else {
|
|
showError(await apiError(response), 'Failed to claim task');
|
|
}
|
|
} catch (error) {
|
|
showError(error.message, 'Failed to claim task');
|
|
}
|
|
}
|
|
|
|
async function completeBead(id) {
|
|
try {
|
|
const response = await fetch('/api/beads/ctl', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: `complete ${id}` })
|
|
});
|
|
|
|
if (response.ok) {
|
|
setTimeout(loadBeads, 500);
|
|
} else {
|
|
showError(await apiError(response), 'Failed to complete task');
|
|
}
|
|
} catch (error) {
|
|
showError(error.message, 'Failed to complete task');
|
|
}
|
|
}
|
|
|
|
// Tasks search box filtering
|
|
document.getElementById('tasks-search-box').addEventListener('input', filterBeads);
|
|
|
|
// ── Daemon status ──────────────────────────────────────────────
|
|
|
|
async function updateDaemonStatus() {
|
|
try {
|
|
const response = await fetch('/api/daemon/status');
|
|
if (!response.ok) {
|
|
document.getElementById('daemon-indicator').className = 'daemon-status-indicator daemon-stopped';
|
|
document.getElementById('daemon-text').textContent = 'Daemon: Error';
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
const indicator = document.getElementById('daemon-indicator');
|
|
const text = document.getElementById('daemon-text');
|
|
|
|
if (data.running) {
|
|
indicator.className = 'daemon-status-indicator daemon-running';
|
|
text.textContent = `Daemon: ${data.session_count} sessions`;
|
|
} else {
|
|
indicator.className = 'daemon-status-indicator daemon-stopped';
|
|
text.textContent = 'Daemon: Stopped';
|
|
}
|
|
} catch (_) {
|
|
document.getElementById('daemon-indicator').className = 'daemon-status-indicator daemon-stopped';
|
|
document.getElementById('daemon-text').textContent = 'Daemon: Unreachable';
|
|
}
|
|
}
|
|
|
|
updateDaemonStatus();
|
|
setInterval(updateDaemonStatus, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|