/**
* 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();
});