/** * Remote Control — Main App (Router + Orchestrator) * UUID-based auth — no login required */ import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js"; import { connectSSE, disconnectSSE } from "./sse.js"; import { appendEvent, showLoading, isLoading, removeLoading, resetReplayState, renderReplayPendingRequests } from "./render.js"; import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js"; import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js"; // ============================================================ // State // ============================================================ let currentSessionId = null; let currentSessionStatus = null; let dashboardInterval = null; let cachedEnvs = []; function generateMessageUuid() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`; } // ============================================================ // Router // ============================================================ function getPathSessionId() { const match = window.location.pathname.match(/^\/code\/([^/]+)/); return match ? match[1] : null; } function getUrlParam(name) { return new URLSearchParams(window.location.search).get(name); } function showPage(name) { const pages = ["dashboard", "session"]; for (const p of pages) { const el = document.getElementById(`page-${p}`); if (el) el.classList.toggle("hidden", p !== name); } } function navigate(path) { history.pushState(null, "", path); handleRoute(); } window.navigate = navigate; function applySessionStatus(status) { currentSessionStatus = status || null; const badge = document.getElementById("session-status"); if (badge) { badge.textContent = status || ""; badge.className = `status-badge status-${statusClass(status)}`; } const closed = isClosedSessionStatus(status); const input = document.getElementById("msg-input"); if (input) { input.disabled = closed; input.placeholder = closed ? "Session is closed" : "Type a message..."; } const actionBtn = document.getElementById("action-btn"); if (actionBtn) { actionBtn.disabled = closed; actionBtn.title = closed ? "Session is closed" : ""; } if (closed) { removeLoading(); window.__updateActionBtn?.(false); } } function handleSessionEvent(event) { if (event?.type === "session_status" && typeof event.payload?.status === "string") { applySessionStatus(event.payload.status); if (isClosedSessionStatus(event.payload.status)) { disconnectSSE(); } } appendEvent(event); } async function syncClosedSessionState(err, actionLabel) { if (!(err instanceof Error)) { alert(`${actionLabel}: unknown error`); return; } if (!currentSessionId || !/session is /i.test(err.message)) { alert(`${actionLabel}: ${err.message}`); return; } try { const session = await apiFetchSession(currentSessionId); applySessionStatus(session.status); if (isClosedSessionStatus(session.status)) { appendEvent({ type: "session_status", payload: { status: session.status } }); return; } } catch { // Fall back to the original error if the refresh also fails. } alert(`${actionLabel}: ${err.message}`); } async function handleRoute() { // Ensure we have a UUID getUuid(); // Check for UUID import from QR scan (?uuid=xxx) const importUuid = getUrlParam("uuid"); if (importUuid) { setUuid(importUuid); const url = new URL(window.location); url.searchParams.delete("uuid"); history.replaceState(null, "", url); } // Check for CLI session bind (?sid=xxx) const sid = getUrlParam("sid"); if (sid) { try { await apiBind(sid); const url = new URL(window.location); url.searchParams.delete("sid"); history.replaceState(null, "", `/code/${sid}`); showPage("session"); stopDashboardRefresh(); renderSessionDetail(sid); return; } catch (err) { console.error("Failed to bind session:", err); alert("Session not found or bind failed: " + err.message); history.replaceState(null, "", "/code/"); } } // Path-based routing: /code/session_xxx → session detail const pathSessionId = getPathSessionId(); if (pathSessionId) { try { await apiBind(pathSessionId); } catch { /* may already be bound */ } showPage("session"); stopDashboardRefresh(); renderSessionDetail(pathSessionId); return; } // Default: /code → dashboard currentSessionId = null; currentSessionStatus = null; showPage("dashboard"); disconnectSSE(); renderDashboard(); startDashboardRefresh(); } window.addEventListener("popstate", handleRoute); // ============================================================ // Dashboard // ============================================================ async function renderDashboard() { try { const [sessions, envs] = await Promise.all([apiFetchAllSessions(), apiFetchEnvironments()]); cachedEnvs = envs || []; renderEnvironmentList(cachedEnvs); renderSessionList(sessions); } catch (err) { console.error("Dashboard render error:", err); } } function renderEnvironmentList(envs) { const container = document.getElementById("env-list"); if (!envs || envs.length === 0) { container.innerHTML = '
No active environments
'; return; } container.innerHTML = envs.map((e) => `
${esc(e.machine_name || e.id)}
${esc(e.directory || "")}
${esc(e.status)}
${e.branch ? esc(e.branch) : ""}
`).join(""); } function renderSessionList(sessions) { const container = document.getElementById("session-list"); if (!sessions || sessions.length === 0) { container.innerHTML = '
No sessions
'; return; } sessions.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0)); container.innerHTML = sessions.map((s) => `
${esc(s.title || s.id)}
${esc(s.id)}
${esc(s.status)} ${formatTime(s.created_at || s.updated_at)}
`).join(""); } function startDashboardRefresh() { stopDashboardRefresh(); dashboardInterval = setInterval(renderDashboard, 10000); } function stopDashboardRefresh() { if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; } } // ============================================================ // Session Detail // ============================================================ async function renderSessionDetail(id) { currentSessionId = id; // Reset task state for new session and init panel resetTaskState(); const taskPanelEl = document.getElementById("task-panel"); if (taskPanelEl) initTaskPanel(taskPanelEl); try { const session = await apiFetchSession(id); document.getElementById("session-title").textContent = session.title || session.id; document.getElementById("session-id").textContent = session.id; document.getElementById("session-env").textContent = session.environment_id || ""; document.getElementById("session-time").textContent = formatTime(session.created_at); applySessionStatus(session.status); } catch (err) { alert("Failed to load session: " + err.message); navigate("/code/"); return; } document.getElementById("event-stream").innerHTML = ""; document.getElementById("permission-area").innerHTML = ""; document.getElementById("permission-area").classList.add("hidden"); // Load historical events before connecting to live stream resetReplayState(); let lastSeqNum = 0; try { const { events } = await apiFetchSessionHistory(id); if (events && events.length > 0) { for (const event of events) { appendEvent(event, { replay: true }); if (event.seqNum && event.seqNum > lastSeqNum) lastSeqNum = event.seqNum; } } } catch (err) { console.warn("Failed to load session history:", err); } // Re-render any still-unresolved permission prompts from history renderReplayPendingRequests(); if (isClosedSessionStatus(currentSessionStatus)) { appendEvent({ type: "session_status", payload: { status: currentSessionStatus } }); disconnectSSE(); return; } connectSSE(id, handleSessionEvent, lastSeqNum); } // ============================================================ // Control Bar // ============================================================ function setupControlBar() { const input = document.getElementById("msg-input"); const actionBtn = document.getElementById("action-btn"); const iconSend = document.getElementById("action-icon-send"); const iconStop = document.getElementById("action-icon-stop"); function setBtnState(loading) { actionBtn.classList.toggle("loading", loading); actionBtn.setAttribute("aria-label", loading ? "Stop" : "Send"); iconSend.classList.toggle("hidden", loading); iconStop.classList.toggle("hidden", !loading); } window.__updateActionBtn = setBtnState; actionBtn.addEventListener("click", () => { if (isLoading()) { doInterrupt(); } else { sendMessage(); } }); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage(); } }); } async function doInterrupt() { if (!currentSessionId || isClosedSessionStatus(currentSessionStatus)) return; const btn = document.getElementById("action-btn"); btn.disabled = true; try { await apiInterrupt(currentSessionId); appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } }); } catch (err) { await syncClosedSessionState(err, "Interrupt failed"); } finally { btn.disabled = isClosedSessionStatus(currentSessionStatus); } } async function sendMessage() { const input = document.getElementById("msg-input"); const text = input.value.trim(); if (!text || !currentSessionId || isClosedSessionStatus(currentSessionStatus)) return; input.value = ""; const uuid = generateMessageUuid(); try { await apiSendEvent(currentSessionId, { type: "user", uuid, content: text, message: { content: text }, }); } catch (err) { input.value = text; await syncClosedSessionState(err, "Failed to send"); } } // ============================================================ // Permission Actions (exposed globally for onclick) // ============================================================ window._approvePerm = async function (requestId, btn) { btn.disabled = true; try { await apiSendControl(currentSessionId, { type: "permission_response", approved: true, request_id: requestId }); removePermissionPrompt(btn); showLoading(); } catch (err) { alert("Failed to approve: " + err.message); btn.disabled = false; } }; window._rejectPerm = async function (requestId, btn) { btn.disabled = true; try { await apiSendControl(currentSessionId, { type: "permission_response", approved: false, request_id: requestId }); removePermissionPrompt(btn); } catch (err) { alert("Failed to reject: " + err.message); btn.disabled = false; } }; // ============================================================ // AskUserQuestion interactions // ============================================================ window._selectOption = function (btn, qIdx, oIdx, multiSelect) { const panel = btn.closest(".ask-panel"); if (!panel) return; if (!panel._answers) panel._answers = {}; if (multiSelect) { // Toggle multi-select btn.classList.toggle("selected"); if (!panel._answers[qIdx]) panel._answers[qIdx] = []; const arr = panel._answers[qIdx]; const pos = arr.indexOf(oIdx); if (pos >= 0) arr.splice(pos, 1); else arr.push(oIdx); } else { // Single select — deselect siblings const siblings = panel.querySelectorAll(`.ask-option[data-qidx="${qIdx}"]`); siblings.forEach((s) => s.classList.remove("selected")); btn.classList.add("selected"); panel._answers[qIdx] = oIdx; } }; window._submitOther = function (btn, qIdx) { const row = btn.closest(".ask-other-row"); const input = row.querySelector(".ask-other-input"); const text = input.value.trim(); if (!text) return; const panel = btn.closest(".ask-panel"); if (!panel) return; if (!panel._answers) panel._answers = {}; panel._answers[qIdx] = text; // Deselect any option buttons panel.querySelectorAll(`.ask-option[data-qidx="${qIdx}"]`).forEach((s) => s.classList.remove("selected")); input.value = ""; btn.textContent = "Sent!"; setTimeout(() => { btn.textContent = "Send"; }, 1000); }; window._switchAskTab = function (btn, idx) { const panel = btn.closest(".ask-panel"); if (!panel) return; panel.querySelectorAll(".ask-tab").forEach((t) => t.classList.remove("active")); panel.querySelectorAll(".ask-tab-page").forEach((p) => p.classList.remove("active")); btn.classList.add("active"); const page = panel.querySelector(`.ask-tab-page[data-tab="${idx}"]`); if (page) page.classList.add("active"); const total = panel.querySelectorAll(".ask-tab").length; const prog = panel.querySelector(".ask-progress"); if (prog) prog.textContent = `${idx + 1} / ${total}`; }; window._submitAnswers = async function (requestId, btn) { btn.disabled = true; const panel = btn.closest(".ask-panel"); const rawAnswers = panel?._answers || {}; const questions = panel?._questions || []; // Build updatedInput: merge original input with user's answers const answers = {}; for (const [qIdx, val] of Object.entries(rawAnswers)) { const q = questions[parseInt(qIdx)]; if (!q) continue; if (typeof val === "string") { // "Other" free-text answer answers[qIdx] = val; } else if (typeof val === "number") { // Selected option index — use label text const opt = q.options?.[val]; answers[qIdx] = opt?.label || String(val); } else if (Array.isArray(val)) { // Multi-select — join labels answers[qIdx] = val.map((i) => q.options?.[i]?.label || String(i)); } } try { await apiSendControl(currentSessionId, { type: "permission_response", approved: true, request_id: requestId, updated_input: { questions, answers }, }); removePermissionPrompt(btn); showLoading(); } catch (err) { alert("Failed to submit: " + err.message); btn.disabled = false; } }; function removePermissionPrompt(btn) { const prompt = btn.closest(".permission-prompt, .ask-panel, .plan-panel"); if (prompt) prompt.remove(); const area = document.getElementById("permission-area"); if (area && area.children.length === 0) area.classList.add("hidden"); } // ============================================================ // ExitPlanMode interactions // ============================================================ window._selectPlanOption = function (btn, value) { const panel = btn.closest(".plan-panel"); if (!panel) return; // Deselect all siblings panel.querySelectorAll(".plan-option").forEach((o) => o.classList.remove("selected")); btn.classList.add("selected"); panel._selectedValue = value; // Show/hide feedback textarea const feedbackArea = panel.querySelector(".plan-feedback-area"); if (feedbackArea) { feedbackArea.classList.toggle("visible", value === "no"); } }; window._submitPlanResponse = async function (requestId, btn) { const panel = btn.closest(".plan-panel"); if (!panel) return; const selectedValue = panel._selectedValue; if (!selectedValue) { alert("Please select an option first."); return; } btn.disabled = true; try { if (selectedValue === "no") { // Rejection with optional feedback const feedbackInput = panel.querySelector(".plan-feedback-input"); const feedback = feedbackInput ? feedbackInput.value.trim() : ""; await apiSendControl(currentSessionId, { type: "permission_response", approved: false, request_id: requestId, ...(feedback ? { message: feedback } : {}), }); removePermissionPrompt(btn); } else { // Approval with permission mode const modeMap = { "yes-accept-edits": "acceptEdits", "yes-default": "default", }; const mode = modeMap[selectedValue] || "default"; const planContent = panel._planContent || ""; await apiSendControl(currentSessionId, { type: "permission_response", approved: true, request_id: requestId, ...(planContent ? { updated_input: { plan: planContent } } : {}), updated_permissions: [ { type: "setMode", mode, destination: "session" }, ], }); removePermissionPrompt(btn); showLoading(); } } catch (err) { alert("Failed to submit: " + err.message); btn.disabled = false; } }; // ============================================================ // New Session Dialog // ============================================================ function setupNewSessionDialog() { const btn = document.getElementById("new-session-btn"); const dialog = document.getElementById("new-session-dialog"); const cancelBtn = document.getElementById("ns-cancel"); const createBtn = document.getElementById("ns-create"); const errorEl = document.getElementById("ns-error"); const titleInput = document.getElementById("ns-title"); const envSelect = document.getElementById("ns-env"); btn.addEventListener("click", () => { envSelect.innerHTML = ''; for (const e of cachedEnvs) { const opt = document.createElement("option"); opt.value = e.id; opt.textContent = `${e.machine_name || e.id} (${e.branch || "no branch"})`; envSelect.appendChild(opt); } errorEl.classList.add("hidden"); titleInput.value = ""; dialog.classList.remove("hidden"); }); cancelBtn.addEventListener("click", () => dialog.classList.add("hidden")); createBtn.addEventListener("click", async () => { createBtn.disabled = true; errorEl.classList.add("hidden"); try { const body = {}; if (titleInput.value.trim()) body.title = titleInput.value.trim(); if (envSelect.value) body.environment_id = envSelect.value; const session = await apiCreateSession(body); dialog.classList.add("hidden"); navigate(`/code/${session.id}`); } catch (err) { errorEl.textContent = err.message || "Failed to create session"; errorEl.classList.remove("hidden"); } finally { createBtn.disabled = false; } }); } // ============================================================ // Identity Panel (QR code display + scan) // ============================================================ function setupIdentityPanel() { const btn = document.getElementById("nav-identity"); const panel = document.getElementById("identity-panel"); const closeBtn = panel.querySelector(".panel-close"); const uuidDisplay = document.getElementById("uuid-display"); const qrContainer = document.getElementById("qr-display"); // Show panel and generate QR code btn.addEventListener("click", () => { const uuid = getUuid(); uuidDisplay.textContent = uuid; const qrUrl = `${window.location.origin}/code?uuid=${encodeURIComponent(uuid)}`; qrContainer.innerHTML = ""; if (typeof QRCode !== "undefined") { new QRCode(qrContainer, { text: qrUrl, width: 200, height: 200, correctLevel: QRCode.CorrectLevel.M }); // qrcodejs generates both canvas and img, hide the duplicate img const img = qrContainer.querySelector("img"); if (img) img.remove() } panel.classList.remove("hidden"); }); closeBtn.addEventListener("click", () => panel.classList.add("hidden")); // Click outside to close panel.addEventListener("click", (e) => { if (e.target === panel) panel.classList.add("hidden"); }); // Copy UUID to clipboard document.getElementById("uuid-copy-btn").addEventListener("click", () => { const uuid = getUuid(); navigator.clipboard.writeText(uuid).then(() => { const btn = document.getElementById("uuid-copy-btn"); btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = "Copy"; }, 2000); }); }); // Scan QR from uploaded image document.getElementById("qr-scan-btn").addEventListener("click", () => { const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const img = new Image(); img.onload = () => { const canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); if (typeof jsQR !== "undefined") { const code = jsQR(imageData.data, imageData.width, imageData.height); if (code && code.data) { try { const url = new URL(code.data); const importedUuid = url.searchParams.get("uuid"); if (importedUuid) { setUuid(importedUuid); panel.classList.add("hidden"); navigate("/code/"); renderDashboard(); return; } } catch { // Not a valid URL — try using raw data as UUID if (code.data.length >= 32) { setUuid(code.data); panel.classList.add("hidden"); navigate("/code/"); renderDashboard(); return; } } alert("No valid UUID found in QR code"); } else { alert("No QR code found in image"); } } }; img.src = URL.createObjectURL(file); }; input.click(); }); } // ============================================================ // Task Panel Toggle // ============================================================ function setupTaskPanelToggle() { window.__toggleTaskPanel = toggleTaskPanel; const toggleBtn = document.getElementById("task-panel-toggle"); if (toggleBtn) { toggleBtn.addEventListener("click", () => toggleTaskPanel()); } } // ============================================================ // Init // ============================================================ document.addEventListener("DOMContentLoaded", () => { setupControlBar(); setupNewSessionDialog(); setupIdentityPanel(); setupTaskPanelToggle(); handleRoute(); });