mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat: 支持自托管的 remote-control-server (#214)
* feat: 支持自托管的 remote-control-server (#214) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
89
packages/remote-control-server/web/api.js
Normal file
89
packages/remote-control-server/web/api.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Remote Control — API Client (UUID-based auth)
|
||||
*/
|
||||
|
||||
const BASE = ""; // same origin
|
||||
|
||||
function generateUuid() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for non-secure contexts (HTTP without localhost)
|
||||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
|
||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16),
|
||||
);
|
||||
}
|
||||
|
||||
export function getUuid() {
|
||||
let uuid = localStorage.getItem("rcs_uuid");
|
||||
if (!uuid) {
|
||||
uuid = generateUuid();
|
||||
localStorage.setItem("rcs_uuid", uuid);
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
|
||||
export function setUuid(uuid) {
|
||||
localStorage.setItem("rcs_uuid", uuid);
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const uuid = getUuid();
|
||||
|
||||
// Append uuid as query param for auth
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
||||
|
||||
const opts = { method, headers };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(url, opts);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const err = data.error || { type: "unknown", message: res.statusText };
|
||||
throw new Error(err.message || err.type);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function apiBind(sessionId) {
|
||||
return api("POST", "/web/bind", { sessionId });
|
||||
}
|
||||
|
||||
export function apiFetchSessions() {
|
||||
return api("GET", "/web/sessions");
|
||||
}
|
||||
|
||||
export function apiFetchAllSessions() {
|
||||
return api("GET", "/web/sessions/all");
|
||||
}
|
||||
|
||||
export function apiFetchSession(id) {
|
||||
return api("GET", `/web/sessions/${id}`);
|
||||
}
|
||||
|
||||
export function apiFetchSessionHistory(id) {
|
||||
return api("GET", `/web/sessions/${id}/history`);
|
||||
}
|
||||
|
||||
export function apiFetchEnvironments() {
|
||||
return api("GET", "/web/environments");
|
||||
}
|
||||
|
||||
export function apiSendEvent(sessionId, body) {
|
||||
return api("POST", `/web/sessions/${sessionId}/events`, body);
|
||||
}
|
||||
|
||||
export function apiSendControl(sessionId, body) {
|
||||
return api("POST", `/web/sessions/${sessionId}/control`, body);
|
||||
}
|
||||
|
||||
export function apiInterrupt(sessionId) {
|
||||
return api("POST", `/web/sessions/${sessionId}/interrupt`);
|
||||
}
|
||||
|
||||
export function apiCreateSession(body) {
|
||||
return api("POST", "/web/sessions", body);
|
||||
}
|
||||
618
packages/remote-control-server/web/app.js
Normal file
618
packages/remote-control-server/web/app.js
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* 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, renderPermissionRequest, showLoading, isLoading, resetReplayState, renderReplayPendingRequests } from "./render.js";
|
||||
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
|
||||
import { esc, formatTime, statusClass } from "./utils.js";
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
|
||||
let currentSessionId = null;
|
||||
let dashboardInterval = null;
|
||||
let cachedEnvs = [];
|
||||
|
||||
// ============================================================
|
||||
// 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;
|
||||
|
||||
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
|
||||
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 = '<div class="empty-state">No active environments</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = envs.map((e) => `
|
||||
<div class="env-card">
|
||||
<div>
|
||||
<div class="env-name">${esc(e.machine_name || e.id)}</div>
|
||||
<div class="env-dir">${esc(e.directory || "")}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<span class="status-badge status-${statusClass(e.status)}">${esc(e.status)}</span>
|
||||
<div class="env-branch">${e.branch ? esc(e.branch) : ""}</div>
|
||||
</div>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function renderSessionList(sessions) {
|
||||
const container = document.getElementById("session-list");
|
||||
if (!sessions || sessions.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No sessions</div>';
|
||||
return;
|
||||
}
|
||||
sessions.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
|
||||
container.innerHTML = sessions.map((s) => `
|
||||
<div class="session-card" onclick="navigate('/code/${esc(s.id)}')">
|
||||
<div>
|
||||
<div class="session-title-text">${esc(s.title || s.id)}</div>
|
||||
<div class="session-id-text">${esc(s.id)}</div>
|
||||
</div>
|
||||
<span class="status-badge status-${statusClass(s.status)}">${esc(s.status)}</span>
|
||||
<span class="meta-item">${formatTime(s.created_at || s.updated_at)}</span>
|
||||
</div>`).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);
|
||||
const badge = document.getElementById("session-status");
|
||||
badge.textContent = session.status;
|
||||
badge.className = `status-badge status-${statusClass(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();
|
||||
|
||||
connectSSE(id, appendEvent, 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) return;
|
||||
const btn = document.getElementById("action-btn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiInterrupt(currentSessionId);
|
||||
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
|
||||
} catch (err) {
|
||||
alert("Interrupt failed: " + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById("msg-input");
|
||||
const text = input.value.trim();
|
||||
if (!text || !currentSessionId) return;
|
||||
input.value = "";
|
||||
try {
|
||||
await apiSendEvent(currentSessionId, { type: "user", content: text });
|
||||
} catch (err) {
|
||||
alert("Failed to send: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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 = '<option value="">-- None --</option>';
|
||||
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();
|
||||
});
|
||||
116
packages/remote-control-server/web/base.css
Normal file
116
packages/remote-control-server/web/base.css
Normal file
@@ -0,0 +1,116 @@
|
||||
/* === CSS Variables — Anthropic Design System === */
|
||||
:root {
|
||||
/* Core palette — warm terracotta system */
|
||||
--bg-primary: #FAF9F6;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-dark: #1A1612;
|
||||
--bg-dark-hover: #2A2520;
|
||||
--bg-dark-elevated: #332E28;
|
||||
--bg-input: #F2EFEA;
|
||||
--bg-input-focus: #FFFFFF;
|
||||
--bg-user-msg: #D97757;
|
||||
--bg-assistant-msg: #FFFFFF;
|
||||
--bg-tool-card: #F5F3EF;
|
||||
--bg-permission: #FFF9F0;
|
||||
--text-primary: #1A1612;
|
||||
--text-secondary: #6B6560;
|
||||
--text-light: #FFFFFF;
|
||||
--text-muted: #9B9590;
|
||||
--text-inverse: #FAF9F6;
|
||||
--border: #E8E4DF;
|
||||
--border-light: #F0ECE7;
|
||||
--border-focus: #D97757;
|
||||
--accent: #D97757;
|
||||
--accent-hover: #C4684A;
|
||||
--accent-subtle: #FDF0EB;
|
||||
--green: #3B8A6A;
|
||||
--green-bg: #E8F5EE;
|
||||
--yellow: #C49A2C;
|
||||
--yellow-bg: #FFF8E8;
|
||||
--orange: #D07A3A;
|
||||
--orange-bg: #FFF3E8;
|
||||
--red: #C44040;
|
||||
--red-bg: #FDE8E8;
|
||||
--blue: #4A7FC4;
|
||||
--blue-bg: #E8F0FD;
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--radius-xs: 6px;
|
||||
--shadow-sm: 0 1px 2px rgba(26, 22, 18, 0.04);
|
||||
--shadow: 0 1px 3px rgba(26, 22, 18, 0.06), 0 2px 8px rgba(26, 22, 18, 0.04);
|
||||
--shadow-md: 0 4px 16px rgba(26, 22, 18, 0.08), 0 1px 4px rgba(26, 22, 18, 0.04);
|
||||
--shadow-lg: 0 8px 32px rgba(26, 22, 18, 0.10), 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
--font-display: "Bricolage Grotesque", system-ui, -apple-system, sans-serif;
|
||||
--font-sans: "Figtree", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "Fira Code", "SF Mono", Menlo, monospace;
|
||||
--max-width: 880px;
|
||||
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* === Reset === */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html {
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Subtle warm ambient light */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(217, 119, 87, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(217, 119, 87, 0.02) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 50% 80%, rgba(59, 138, 106, 0.02) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
body > * { position: relative; z-index: 1; }
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
input, select, textarea { font-family: inherit; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* === Selection === */
|
||||
::selection {
|
||||
background: rgba(217, 119, 87, 0.2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* === Focus Ring === */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* === Scrollbar === */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
233
packages/remote-control-server/web/components.css
Normal file
233
packages/remote-control-server/web/components.css
Normal file
@@ -0,0 +1,233 @@
|
||||
/* === Navbar — Anthropic === */
|
||||
nav {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
letter-spacing: -0.01em;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.nav-logo:hover { opacity: 0.7; text-decoration: none; }
|
||||
.nav-logo svg { flex-shrink: 0; }
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: all var(--transition-fast);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-text { background: none; border: none; color: inherit; }
|
||||
|
||||
/* === Buttons — Anthropic === */
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-light);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 22px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: 0 1px 2px rgba(217, 119, 87, 0.2);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:active { transform: translateY(0); box-shadow: none; }
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red);
|
||||
color: var(--text-light);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 18px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-danger:hover { background: #B33838; transform: translateY(-1px); }
|
||||
.btn-danger:active { transform: translateY(0); }
|
||||
|
||||
.btn-sm { padding: 8px 16px; font-size: 0.85rem; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-outline:hover {
|
||||
background: var(--bg-input);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: var(--green);
|
||||
color: var(--text-light);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-approve:hover { background: #347A5E; transform: translateY(-1px); }
|
||||
.btn-approve:active { transform: translateY(0); }
|
||||
|
||||
.btn-reject {
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
border: 1.5px solid var(--red);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-reject:hover { background: var(--red-bg); transform: translateY(-1px); }
|
||||
.btn-reject:active { transform: translateY(0); }
|
||||
|
||||
/* === Status Badge — Anthropic === */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-active, .status-running { background: var(--green-bg); color: var(--green); }
|
||||
.status-idle { background: var(--yellow-bg); color: var(--yellow); }
|
||||
.status-requires_action { background: var(--orange-bg); color: var(--orange); }
|
||||
.status-archived { background: #F0ECE7; color: var(--text-secondary); }
|
||||
.status-error { background: var(--red-bg); color: var(--red); }
|
||||
.status-default { background: #F0ECE7; color: var(--text-muted); }
|
||||
|
||||
/* === Dialog — Anthropic === */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(26, 22, 18, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
animation: fadeIn var(--transition-fast) ease-out;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
border: 1px solid var(--border-light);
|
||||
animation: slideUp var(--transition-base) ease-out;
|
||||
}
|
||||
.dialog-card h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.dialog-card label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
margin-top: 16px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dialog-card input,
|
||||
.dialog-card select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.dialog-card input:focus,
|
||||
.dialog-card select:focus {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-input-focus);
|
||||
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* === Animations === */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
151
packages/remote-control-server/web/index.html
Normal file
151
packages/remote-control-server/web/index.html
Normal file
@@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Remote Control — Claude Code</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Figtree:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Nav Bar -->
|
||||
<nav id="navbar">
|
||||
<div class="nav-inner">
|
||||
<a href="/code/" class="nav-logo">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path d="M10 1L12.2 7.8L19 10L12.2 12.2L10 19L7.8 12.2L1 10L7.8 7.8L10 1Z" fill="#D97757"/>
|
||||
</svg>
|
||||
Remote Control
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="/code/" class="nav-link" id="nav-dashboard">Dashboard</a>
|
||||
<button id="nav-identity" class="nav-link btn-text" title="Identity & QR">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:-2px;margin-right:4px;">
|
||||
<path d="M6 8C7.66 8 9 6.66 9 5C9 3.34 7.66 2 6 2C4.34 2 3 3.34 3 5C3 6.66 4.34 8 6 8ZM6 10C3.99 10 0 11.01 0 13V14H12V13C12 11.01 8.01 10 6 10ZM13 8V5H11V8H8V10H11V13H13V10H16V8H13Z" fill="currentColor"/>
|
||||
</svg>
|
||||
Identity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard Page -->
|
||||
<section id="page-dashboard" class="page hidden">
|
||||
<div class="dashboard-container">
|
||||
<!-- Environments -->
|
||||
<div class="dashboard-section">
|
||||
<h2 class="section-title">Environments</h2>
|
||||
<div id="env-list" class="card-list">
|
||||
<div class="empty-state">No active environments</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sessions -->
|
||||
<div class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Sessions</h2>
|
||||
<button id="new-session-btn" class="btn-primary btn-sm">+ New Session</button>
|
||||
</div>
|
||||
<div id="session-list" class="card-list">
|
||||
<div class="empty-state">No sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Session Dialog -->
|
||||
<div id="new-session-dialog" class="dialog-overlay hidden">
|
||||
<div class="dialog-card">
|
||||
<h3>New Session</h3>
|
||||
<label for="ns-title">Title (optional)</label>
|
||||
<input type="text" id="ns-title" placeholder="My session" />
|
||||
<label for="ns-env">Environment</label>
|
||||
<select id="ns-env"></select>
|
||||
<div id="ns-error" class="error-msg hidden"></div>
|
||||
<div class="dialog-actions">
|
||||
<button id="ns-cancel" class="btn-outline">Cancel</button>
|
||||
<button id="ns-create" class="btn-primary">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Session Detail Page -->
|
||||
<section id="page-session" class="page hidden">
|
||||
<div class="session-container">
|
||||
<!-- Header -->
|
||||
<div class="session-header">
|
||||
<a href="/code/" class="back-link">← Dashboard</a>
|
||||
<div class="session-meta">
|
||||
<h2 id="session-title" class="session-detail-title">Session</h2>
|
||||
<div class="session-meta-row">
|
||||
<span id="session-id" class="meta-item"></span>
|
||||
<span id="session-status" class="status-badge"></span>
|
||||
<span id="session-env" class="meta-item"></span>
|
||||
<span id="session-time" class="meta-item"></span>
|
||||
<button id="task-panel-toggle" class="nav-link btn-text" title="Tasks & Todos">
|
||||
Tasks <span id="task-badge" class="task-count-badge hidden">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Event Stream -->
|
||||
<div id="event-stream" class="event-stream"></div>
|
||||
<!-- Permission Prompt Area -->
|
||||
<div id="permission-area" class="hidden"></div>
|
||||
<!-- Control Bar -->
|
||||
<div class="control-bar">
|
||||
<input type="text" id="msg-input" placeholder="Type a message..." autocomplete="off" />
|
||||
<button id="action-btn" class="action-btn" aria-label="Send">
|
||||
<svg id="action-icon-send" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M3 10L17 3L10 17L9 11L3 10Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<svg id="action-icon-stop" class="hidden" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect x="3" y="3" width="12" height="12" rx="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Task Panel -->
|
||||
<div id="task-panel" class="task-panel hidden"></div>
|
||||
|
||||
<!-- Identity Panel (QR display + scan) -->
|
||||
<div id="identity-panel" class="identity-panel hidden">
|
||||
<div class="identity-panel-inner">
|
||||
<div class="identity-panel-header">
|
||||
<h3>Identity</h3>
|
||||
<button class="panel-close">×</button>
|
||||
</div>
|
||||
<div class="identity-panel-body">
|
||||
<div class="identity-section">
|
||||
<label>Your UUID</label>
|
||||
<div class="uuid-row">
|
||||
<code id="uuid-display" class="uuid-text"></code>
|
||||
<button id="uuid-copy-btn" class="btn-outline btn-sm">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="identity-section">
|
||||
<label>Scan on another device</label>
|
||||
<div id="qr-display" class="qr-container"></div>
|
||||
</div>
|
||||
<div class="identity-section">
|
||||
<label>Import identity from QR</label>
|
||||
<button id="qr-scan-btn" class="btn-outline">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:-2px;margin-right:4px;">
|
||||
<path d="M1 1H5V3H3V5H1V1ZM11 1H15V5H13V3H11V1ZM1 11H3V13H5V15H1V11ZM13 11H15V15H11V13H13V11ZM6 6H10V10H6V6Z" fill="currentColor"/>
|
||||
</svg>
|
||||
Upload QR Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Libraries -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
481
packages/remote-control-server/web/messages.css
Normal file
481
packages/remote-control-server/web/messages.css
Normal file
@@ -0,0 +1,481 @@
|
||||
/* === Event Stream === */
|
||||
.event-stream {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* === Message Bubbles — Anthropic / Claude === */
|
||||
.msg-row {
|
||||
display: flex;
|
||||
max-width: 82%;
|
||||
animation: msgIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes msgIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.msg-row.user { align-self: flex-end; }
|
||||
.msg-row.assistant { align-self: flex-start; }
|
||||
.msg-row.tool { align-self: flex-start; max-width: 95%; }
|
||||
.msg-row.system { align-self: center; }
|
||||
.msg-row.result { align-self: center; }
|
||||
|
||||
.msg-bubble {
|
||||
padding: 12px 18px;
|
||||
border-radius: 18px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg-row.user .msg-bubble {
|
||||
background: var(--accent);
|
||||
color: var(--text-light);
|
||||
border-bottom-right-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.2);
|
||||
}
|
||||
|
||||
.msg-row.assistant .msg-bubble {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-bottom-left-radius: 6px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.msg-row.system .msg-bubble {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.msg-row.result .msg-bubble {
|
||||
background: var(--green-bg);
|
||||
color: var(--green);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 6px 16px;
|
||||
border-radius: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* === Tool Cards — Anthropic === */
|
||||
.tool-card {
|
||||
background: var(--bg-tool-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
width: 100%;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.tool-card:hover { border-color: var(--accent); }
|
||||
|
||||
.tool-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.tool-card-header:hover { color: var(--text-primary); }
|
||||
|
||||
.tool-card-header .tool-icon {
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.tool-card-header:hover .tool-icon { transform: rotate(90deg); }
|
||||
|
||||
.tool-card-body {
|
||||
margin-top: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 12px 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.tool-card-body.collapsed { display: none; }
|
||||
|
||||
/* === Permission Prompt — Anthropic === */
|
||||
.permission-prompt {
|
||||
background: var(--bg-permission);
|
||||
border: 1px solid #F0D9A8;
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
margin-top: 8px;
|
||||
max-width: 95%;
|
||||
align-self: flex-start;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.permission-prompt .perm-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--orange);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.permission-prompt .perm-tool {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
background: var(--bg-card);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-xs);
|
||||
margin-bottom: 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.permission-prompt .perm-actions { display: flex; gap: 10px; }
|
||||
.permission-prompt .perm-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.permission-prompt .perm-tool-name {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* === AskUserQuestion Panel === */
|
||||
.ask-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1.5px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
margin-top: 8px;
|
||||
max-width: 95%;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 2px 12px rgba(217, 119, 87, 0.15);
|
||||
}
|
||||
.ask-panel .ask-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ask-question {
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.ask-question:last-of-type { border-bottom: none; margin-bottom: 12px; }
|
||||
.ask-question-text {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ask-header {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ask-options { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ask-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ask-option:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(217, 119, 87, 0.04);
|
||||
}
|
||||
.ask-option.selected {
|
||||
border-color: var(--accent);
|
||||
background: rgba(217, 119, 87, 0.1);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
.ask-option-label { font-weight: 500; }
|
||||
.ask-option-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ask-other-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ask-other-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.ask-other-input:focus { border-color: var(--accent); }
|
||||
.ask-other-btn {
|
||||
padding: 8px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.ask-other-btn:hover { border-color: var(--accent); }
|
||||
.ask-actions { display: flex; gap: 10px; margin-top: 8px; }
|
||||
.ask-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1.5px solid var(--border);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.ask-tab {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1.5px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.ask-tab:hover { color: var(--text-primary); }
|
||||
.ask-tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.ask-tab-page { display: none; }
|
||||
.ask-tab-page.active { display: block; }
|
||||
.ask-tab-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
.ask-progress {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* === ExitPlanMode Panel === */
|
||||
.plan-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1.5px solid #7C6FA0;
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
margin-top: 8px;
|
||||
max-width: 95%;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 2px 12px rgba(124, 111, 160, 0.18);
|
||||
}
|
||||
.plan-panel .plan-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
color: #7C6FA0;
|
||||
}
|
||||
.plan-panel .plan-content {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.plan-panel .plan-content pre {
|
||||
background: var(--bg-tool-card);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 6px 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.plan-panel .plan-content code {
|
||||
background: var(--bg-tool-card);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.plan-panel .plan-content strong { font-weight: 600; }
|
||||
.plan-options { display: flex; flex-direction: column; gap: 6px; }
|
||||
.plan-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
gap: 10px;
|
||||
}
|
||||
.plan-option:hover {
|
||||
border-color: #7C6FA0;
|
||||
background: rgba(124, 111, 160, 0.04);
|
||||
}
|
||||
.plan-option.selected {
|
||||
border-color: #7C6FA0;
|
||||
background: rgba(124, 111, 160, 0.1);
|
||||
box-shadow: 0 0 0 1px #7C6FA0;
|
||||
}
|
||||
.plan-option-label { font-weight: 500; }
|
||||
.plan-option-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.plan-feedback-area {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
}
|
||||
.plan-feedback-area.visible { display: block; }
|
||||
.plan-feedback-input {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.plan-feedback-input:focus { border-color: #7C6FA0; }
|
||||
.plan-actions { display: flex; gap: 10px; margin-top: 12px; }
|
||||
.plan-actions .btn-plan-submit {
|
||||
background: #7C6FA0;
|
||||
color: var(--text-light);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.plan-actions .btn-plan-submit:hover { background: #6B5E90; }
|
||||
.plan-actions .btn-plan-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* === Timestamps === */
|
||||
.event-time { font-size: 0.7rem; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* === Loading Indicator — TUI star spinner === */
|
||||
.msg-row.loading-row {
|
||||
align-self: flex-start;
|
||||
max-width: 82%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 4px;
|
||||
animation: msgIn 0.3s ease-out;
|
||||
}
|
||||
.tui-spinner {
|
||||
font-size: 1.2rem;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
min-width: 1.2em;
|
||||
transition: color 2s ease;
|
||||
}
|
||||
.stalled .tui-spinner { color: var(--red); }
|
||||
.tui-verb {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
transition: color 2s ease;
|
||||
}
|
||||
.stalled .tui-verb { color: var(--red); }
|
||||
|
||||
/* Glimmer — reverse sweep highlight (same visual as TUI) */
|
||||
.glimmer-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--text-secondary) 0%,
|
||||
var(--text-secondary) 40%,
|
||||
var(--accent) 50%,
|
||||
var(--text-secondary) 60%,
|
||||
var(--text-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: glimmerSweep 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes glimmerSweep {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
.stalled .glimmer-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--red) 0%,
|
||||
var(--red) 40%,
|
||||
#E06060 50%,
|
||||
var(--red) 60%,
|
||||
var(--red) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.tui-timer {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
margin-left: auto;
|
||||
}
|
||||
427
packages/remote-control-server/web/pages.css
Normal file
427
packages/remote-control-server/web/pages.css
Normal file
@@ -0,0 +1,427 @@
|
||||
/* === Pages === */
|
||||
.page {
|
||||
min-height: calc(100vh - 56px);
|
||||
animation: pageIn var(--transition-slow) ease-out;
|
||||
}
|
||||
.page.no-nav { min-height: 100vh; }
|
||||
|
||||
@keyframes pageIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* === Login — Anthropic === */
|
||||
#page-login {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 20%, rgba(217, 119, 87, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 80%, rgba(59, 138, 106, 0.04) 0%, transparent 50%),
|
||||
var(--bg-primary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border: 1px solid var(--border-light);
|
||||
animation: cardIn var(--transition-slow) ease-out;
|
||||
}
|
||||
|
||||
@keyframes cardIn {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.login-header { text-align: center; margin-bottom: 36px; }
|
||||
.login-header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#login-form label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#login-form input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
#login-form input:focus {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-input-focus);
|
||||
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--red);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--red-bg);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
#login-btn { margin-top: 20px; width: 100%; padding: 13px; font-size: 0.95rem; }
|
||||
#login-form { margin-top: 24px; }
|
||||
|
||||
/* === Dashboard — Anthropic === */
|
||||
.dashboard-container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
.dashboard-section { margin-bottom: 40px; }
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section-header .section-title { margin-bottom: 0; }
|
||||
|
||||
.card-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px dashed var(--border);
|
||||
}
|
||||
|
||||
/* Environment Card */
|
||||
.env-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.env-card:hover {
|
||||
box-shadow: var(--shadow);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.env-card .env-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.env-card .env-dir {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.env-card .env-branch {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Session Card */
|
||||
.session-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.session-card:hover {
|
||||
box-shadow: var(--shadow);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.session-card:active { transform: translateY(0); }
|
||||
.session-card .session-title-text {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.session-card .session-id-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* === Session Detail — Anthropic === */
|
||||
.session-container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.back-link:hover { color: var(--accent); text-decoration: none; }
|
||||
|
||||
.session-header { margin-bottom: 24px; }
|
||||
|
||||
.session-detail-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.session-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.meta-item {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* === Control Bar — Claude-style === */
|
||||
.control-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#msg-input {
|
||||
flex: 1;
|
||||
padding: 12px 18px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 24px;
|
||||
background: var(--bg-card);
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
#msg-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.1), var(--shadow);
|
||||
}
|
||||
#msg-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* Circular action button */
|
||||
.action-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--text-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.25);
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 3px 12px rgba(217, 119, 87, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
.action-btn.loading {
|
||||
background: var(--red);
|
||||
box-shadow: 0 2px 8px rgba(200, 60, 60, 0.25);
|
||||
}
|
||||
.action-btn.loading:hover {
|
||||
background: #B33838;
|
||||
box-shadow: 0 3px 12px rgba(200, 60, 60, 0.35);
|
||||
}
|
||||
.action-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.action-btn svg { display: block; }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 640px) {
|
||||
.login-card { margin: 16px; padding: 32px 24px; }
|
||||
.dashboard-container, .session-container { padding: 20px 16px; }
|
||||
.session-card { grid-template-columns: 1fr; gap: 6px; }
|
||||
.env-card { grid-template-columns: 1fr; }
|
||||
.msg-row { max-width: 95%; }
|
||||
.session-meta-row { flex-direction: column; gap: 4px; align-items: flex-start; }
|
||||
.control-bar { flex-wrap: nowrap; }
|
||||
#msg-input { min-width: 0; }
|
||||
.identity-panel-inner { width: 100%; max-width: 100%; }
|
||||
}
|
||||
|
||||
/* === Identity Panel (QR code + scan) === */
|
||||
.identity-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn var(--transition-fast) ease-out;
|
||||
}
|
||||
.identity-panel.hidden { display: none; }
|
||||
|
||||
.identity-panel-inner {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg, 20px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--border-light);
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: cardIn var(--transition-slow) ease-out;
|
||||
}
|
||||
|
||||
.identity-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.identity-panel-header h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.panel-close:hover { color: var(--text-primary); }
|
||||
|
||||
.identity-panel-body {
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
.identity-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.identity-section:last-child { margin-bottom: 0; }
|
||||
.identity-section label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.uuid-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.uuid-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
padding: 8px 12px;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.qr-container canvas,
|
||||
.qr-container img {
|
||||
display: block !important;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
}
|
||||
|
||||
#qr-scan-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
637
packages/remote-control-server/web/render.js
Normal file
637
packages/remote-control-server/web/render.js
Normal file
@@ -0,0 +1,637 @@
|
||||
/**
|
||||
* Remote Control — Event Rendering
|
||||
*
|
||||
* Renders session events into DOM elements for the event stream.
|
||||
*/
|
||||
|
||||
import { esc } from "./utils.js";
|
||||
import { processAssistantEvent } from "./task-panel.js";
|
||||
|
||||
// ============================================================
|
||||
// Replay state — tracks unresolved permission requests during history replay
|
||||
// ============================================================
|
||||
|
||||
const replayPendingRequests = new Map(); // request_id → event data (unresolved)
|
||||
const replayRespondedRequests = new Set(); // request_ids that have a response
|
||||
|
||||
/** Clear replay tracking state (call before each history load) */
|
||||
export function resetReplayState() {
|
||||
replayPendingRequests.clear();
|
||||
replayRespondedRequests.clear();
|
||||
}
|
||||
|
||||
/** After replay finishes, render any still-unresolved permission prompts */
|
||||
export function renderReplayPendingRequests() {
|
||||
if (replayPendingRequests.size === 0) return;
|
||||
|
||||
// Sort by seqNum to maintain order
|
||||
const sorted = [...replayPendingRequests.entries()].sort((a, b) => (a[1].seqNum || 0) - (b[1].seqNum || 0));
|
||||
for (const [, data] of sorted) {
|
||||
// Re-invoke appendEvent without replay flag to go through the normal interactive path
|
||||
appendEvent(data, { replay: false });
|
||||
}
|
||||
replayPendingRequests.clear();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
function truncate(str, max) {
|
||||
if (!str) return "";
|
||||
const s = String(str);
|
||||
return s.length > max ? s.slice(0, max) + "..." : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from an event payload.
|
||||
* Server-side normalization guarantees payload.content is a string.
|
||||
* Falls back to raw/message parsing for backward compat.
|
||||
*/
|
||||
export function extractText(payload) {
|
||||
if (!payload) return "";
|
||||
|
||||
// Normalized format (server standardized)
|
||||
if (typeof payload.content === "string" && payload.content) return payload.content;
|
||||
|
||||
// Fallback: raw message.content (child process format)
|
||||
const msg = payload.message;
|
||||
if (msg && typeof msg === "object") {
|
||||
const mc = msg.content;
|
||||
if (typeof mc === "string") return mc;
|
||||
if (Array.isArray(mc)) {
|
||||
return mc
|
||||
.filter((b) => b && typeof b === "object" && b.type === "text")
|
||||
.map((b) => b.text || "")
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function formatAssistantContent(content) {
|
||||
let html = esc(content);
|
||||
// Code blocks: ```...```
|
||||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
return `<pre style="background:var(--bg-tool-card);padding:10px;border-radius:6px;overflow-x:auto;margin:6px 0;font-family:var(--font-mono);font-size:0.82rem;">${code.trim()}</pre>`;
|
||||
});
|
||||
// Inline code: `...`
|
||||
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool-card);padding:2px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em;">$1</code>');
|
||||
// Bold: **...**
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
return html;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Router
|
||||
// ============================================================
|
||||
|
||||
export function appendEvent(data, { replay = false } = {}) {
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
|
||||
const type = data.type || "unknown";
|
||||
const payload = data.payload || {};
|
||||
const direction = data.direction || "inbound";
|
||||
|
||||
// Early filter: skip bridge init noise regardless of event type
|
||||
const serialized = JSON.stringify(data);
|
||||
if (/Remote Control connecting/i.test(serialized)) return;
|
||||
|
||||
// During history replay, only render messages & tools — skip interactive/stateful events
|
||||
// Exception: unresolved permission/control requests are re-shown as pending prompts.
|
||||
if (replay) {
|
||||
let histEl;
|
||||
switch (type) {
|
||||
case "user":
|
||||
if (direction === "outbound") histEl = renderUserMessage(payload, direction);
|
||||
break;
|
||||
case "assistant":
|
||||
{
|
||||
const text = extractText(payload);
|
||||
if (text && text.trim()) histEl = renderAssistantMessage(payload);
|
||||
processAssistantEvent(payload);
|
||||
}
|
||||
break;
|
||||
case "tool_use":
|
||||
histEl = renderToolUse(payload);
|
||||
break;
|
||||
case "tool_result":
|
||||
histEl = renderToolResult(payload);
|
||||
break;
|
||||
case "error":
|
||||
histEl = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`);
|
||||
break;
|
||||
case "control_request":
|
||||
case "permission_request":
|
||||
// Track unanswered permission/control requests for replay
|
||||
if (payload.request && payload.request.subtype === "can_use_tool" && direction === "inbound") {
|
||||
const rid = payload.request_id || data.id;
|
||||
if (rid && !replayRespondedRequests.has(rid)) {
|
||||
replayPendingRequests.set(rid, data);
|
||||
}
|
||||
}
|
||||
return;
|
||||
case "control_response":
|
||||
case "permission_response":
|
||||
// Mark the corresponding request as resolved
|
||||
{
|
||||
const respRid = payload.request_id;
|
||||
if (respRid) {
|
||||
replayRespondedRequests.add(respRid);
|
||||
replayPendingRequests.delete(respRid);
|
||||
}
|
||||
}
|
||||
return;
|
||||
// Skip: partial_assistant, result, status, interrupt, system, user inbound echoes
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (histEl) {
|
||||
stream.appendChild(histEl);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let el;
|
||||
let needLoading = false;
|
||||
|
||||
switch (type) {
|
||||
case "user":
|
||||
// Skip inbound user messages — they're echoes of what we already sent
|
||||
if (direction === "inbound") return;
|
||||
el = renderUserMessage(payload, direction);
|
||||
needLoading = true;
|
||||
break;
|
||||
case "partial_assistant":
|
||||
// Skip partial assistant — wait for the final "assistant" event
|
||||
// to avoid blank/duplicate messages during streaming
|
||||
return;
|
||||
case "assistant":
|
||||
removeLoading();
|
||||
{
|
||||
const text = extractText(payload);
|
||||
if (text && text.trim()) el = renderAssistantMessage(payload);
|
||||
processAssistantEvent(payload);
|
||||
}
|
||||
break;
|
||||
case "result":
|
||||
case "result_success":
|
||||
removeLoading();
|
||||
// Skip result — it just repeats the assistant message content
|
||||
return;
|
||||
case "tool_use":
|
||||
el = renderToolUse(payload);
|
||||
break;
|
||||
case "tool_result":
|
||||
el = renderToolResult(payload);
|
||||
break;
|
||||
case "control_request":
|
||||
case "permission_request":
|
||||
if (payload.request && payload.request.subtype === "can_use_tool") {
|
||||
const toolName = payload.request.tool_name || "unknown";
|
||||
const toolInput = payload.request.input || payload.request.tool_input || {};
|
||||
if (toolName === "AskUserQuestion") {
|
||||
el = renderAskUserQuestion({
|
||||
request_id: payload.request_id || data.id,
|
||||
tool_input: toolInput,
|
||||
description: payload.request.description || "",
|
||||
});
|
||||
} else if (toolName === "ExitPlanMode") {
|
||||
el = renderExitPlanMode({
|
||||
request_id: payload.request_id || data.id,
|
||||
tool_input: toolInput,
|
||||
description: payload.request.description || "",
|
||||
});
|
||||
} else {
|
||||
el = renderPermissionRequest({
|
||||
request_id: payload.request_id || data.id,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
description: payload.request.description || "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
el = renderSystemMessage(`Control: ${payload.request?.subtype || "unknown"}`);
|
||||
}
|
||||
break;
|
||||
case "control_response":
|
||||
case "permission_response":
|
||||
// Skip — these are just acknowledgments, no need to show in stream
|
||||
return;
|
||||
case "status":
|
||||
// Skip connecting/waiting status noise from bridge
|
||||
{
|
||||
const msg = payload.message || payload.content || "";
|
||||
const fullText = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
if (/connecting|waiting|initializing|Remote Control/i.test(msg + " " + fullText)) return;
|
||||
if (!msg.trim()) return;
|
||||
el = renderSystemMessage(msg);
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
removeLoading();
|
||||
el = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`);
|
||||
break;
|
||||
case "interrupt":
|
||||
removeLoading();
|
||||
el = renderSystemMessage("Session interrupted");
|
||||
break;
|
||||
case "system":
|
||||
// Skip raw system/init messages — they're noise
|
||||
return;
|
||||
default: {
|
||||
// Skip noise from bridge init
|
||||
const raw = JSON.stringify(payload);
|
||||
if (/Remote Control connecting/i.test(raw)) return;
|
||||
el = renderSystemMessage(`${type}: ${truncate(raw, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (el) {
|
||||
stream.appendChild(el);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// Show loading after the message element is in the DOM so it renders below
|
||||
if (needLoading) showLoading();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Renderers
|
||||
// ============================================================
|
||||
|
||||
function renderUserMessage(payload, direction) {
|
||||
const content = extractText(payload);
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row user";
|
||||
row.innerHTML = `<div class="msg-bubble">${esc(content)}</div>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderAssistantMessage(payload) {
|
||||
const content = extractText(payload);
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row assistant";
|
||||
row.innerHTML = `<div class="msg-bubble">${formatAssistantContent(content)}</div>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
const text = payload.result || payload.subtype || "Session completed";
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row system result";
|
||||
row.innerHTML = `<div class="msg-bubble">✓ ${esc(text)}</div>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderToolUse(payload) {
|
||||
const name = payload.tool_name || payload.name || "tool";
|
||||
const input = payload.tool_input || payload.input || {};
|
||||
const inputStr = typeof input === "string" ? input : JSON.stringify(input, null, 2);
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg-row tool";
|
||||
card.innerHTML = `
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
||||
<span class="tool-icon">▶</span> Tool: <strong>${esc(name)}</strong>
|
||||
</div>
|
||||
<div class="tool-card-body collapsed">${esc(truncate(inputStr, 2000))}</div>
|
||||
</div>`;
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderToolResult(payload) {
|
||||
const content = payload.content || payload.output || "";
|
||||
const contentStr = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg-row tool";
|
||||
card.innerHTML = `
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
||||
<span class="tool-icon">▶</span> Tool Result
|
||||
</div>
|
||||
<div class="tool-card-body collapsed">${esc(truncate(contentStr, 2000))}</div>
|
||||
</div>`;
|
||||
return card;
|
||||
}
|
||||
|
||||
export function renderPermissionRequest(payload) {
|
||||
const requestId = payload.request_id || payload.id || "";
|
||||
const toolName = payload.tool_name || "unknown";
|
||||
const toolInput = payload.tool_input || payload.input || {};
|
||||
const description = payload.description || "";
|
||||
const inputStr = typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput, null, 2);
|
||||
|
||||
const area = document.getElementById("permission-area");
|
||||
area.classList.remove("hidden");
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "permission-prompt";
|
||||
el.dataset.requestId = requestId;
|
||||
el.innerHTML = `
|
||||
<div class="perm-title">Permission Request</div>
|
||||
${description ? `<div class="perm-desc">${esc(description)}</div>` : ""}
|
||||
<div class="perm-tool-name"><strong>${esc(toolName)}</strong></div>
|
||||
${toolName !== "AskUserQuestion" ? `<div class="perm-tool">${esc(truncate(inputStr, 500))}</div>` : ""}
|
||||
<div class="perm-actions">
|
||||
<button class="btn-approve" onclick="window._approvePerm('${esc(requestId)}', this)">Approve</button>
|
||||
<button class="btn-reject" onclick="window._rejectPerm('${esc(requestId)}', this)">Reject</button>
|
||||
</div>`;
|
||||
area.appendChild(el);
|
||||
|
||||
return renderSystemMessage(`Permission requested: ${toolName}`);
|
||||
}
|
||||
|
||||
export function renderAskUserQuestion(payload) {
|
||||
const requestId = payload.request_id || payload.id || "";
|
||||
const questions = payload.tool_input?.questions || [];
|
||||
const description = payload.description || "";
|
||||
|
||||
const area = document.getElementById("permission-area");
|
||||
area.classList.remove("hidden");
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "ask-panel";
|
||||
el.dataset.requestId = requestId;
|
||||
|
||||
// Single question — no tabs needed
|
||||
if (questions.length <= 1) {
|
||||
const q = questions[0] || {};
|
||||
const multiSelect = q.multiSelect || false;
|
||||
el.innerHTML = `
|
||||
<div class="ask-title">${esc(description || q.question || "Question")}</div>
|
||||
<div class="ask-options">
|
||||
${(q.options || []).map((opt, j) => `
|
||||
<button class="ask-option${multiSelect ? " ask-multi" : ""}" data-qidx="0" data-oidx="${j}"
|
||||
onclick="window._selectOption(this, 0, ${j}, ${multiSelect})">
|
||||
<span class="ask-option-label">${esc(opt.label || "")}</span>
|
||||
${opt.description ? `<span class="ask-option-desc">${esc(opt.description)}</span>` : ""}
|
||||
</button>
|
||||
`).join("")}
|
||||
<div class="ask-other-row">
|
||||
<input type="text" class="ask-other-input" data-qidx="0" placeholder="Other..." />
|
||||
<button class="ask-other-btn" onclick="window._submitOther(this, 0)">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ask-actions">
|
||||
<button class="btn-approve" onclick="window._submitAnswers('${esc(requestId)}', this)">Submit</button>
|
||||
<button class="btn-reject" onclick="window._rejectPerm('${esc(requestId)}', this)">Skip</button>
|
||||
</div>`;
|
||||
} else {
|
||||
// Multiple questions — tab layout
|
||||
const tabs = questions.map((q, i) => {
|
||||
const multiSelect = q.multiSelect || false;
|
||||
return `
|
||||
<div class="ask-tab-page${i === 0 ? " active" : ""}" data-tab="${i}">
|
||||
<div class="ask-question-text">${esc(q.question || "")}</div>
|
||||
${q.header ? `<div class="ask-header">${esc(q.header)}</div>` : ""}
|
||||
<div class="ask-options">
|
||||
${(q.options || []).map((opt, j) => `
|
||||
<button class="ask-option${multiSelect ? " ask-multi" : ""}" data-qidx="${i}" data-oidx="${j}"
|
||||
onclick="window._selectOption(this, ${i}, ${j}, ${multiSelect})">
|
||||
<span class="ask-option-label">${esc(opt.label || "")}</span>
|
||||
${opt.description ? `<span class="ask-option-desc">${esc(opt.description)}</span>` : ""}
|
||||
</button>
|
||||
`).join("")}
|
||||
<div class="ask-other-row">
|
||||
<input type="text" class="ask-other-input" data-qidx="${i}" placeholder="Other..." />
|
||||
<button class="ask-other-btn" onclick="window._submitOther(this, ${i})">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const tabBar = questions.map((q, i) =>
|
||||
`<button class="ask-tab${i === 0 ? " active" : ""}" onclick="window._switchAskTab(this, ${i})">${esc(q.header || `Q${i + 1}`)}</button>`
|
||||
).join("");
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="ask-title">${esc(description || "Questions")}</div>
|
||||
<div class="ask-tabs">${tabBar}</div>
|
||||
${tabs}
|
||||
<div class="ask-tab-footer">
|
||||
<span class="ask-progress">1 / ${questions.length}</span>
|
||||
<div class="ask-actions">
|
||||
<button class="btn-approve" onclick="window._submitAnswers('${esc(requestId)}', this)">Submit All</button>
|
||||
<button class="btn-reject" onclick="window._rejectPerm('${esc(requestId)}', this)">Skip</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
area.appendChild(el);
|
||||
|
||||
// Track selected options and store original questions for answer mapping
|
||||
el._answers = {};
|
||||
el._questions = questions;
|
||||
|
||||
return renderSystemMessage("Waiting for your response...");
|
||||
}
|
||||
|
||||
export function renderExitPlanMode(payload) {
|
||||
const requestId = payload.request_id || payload.id || "";
|
||||
const toolInput = payload.tool_input || {};
|
||||
const description = payload.description || "";
|
||||
const planContent = toolInput.plan || "";
|
||||
|
||||
const area = document.getElementById("permission-area");
|
||||
area.classList.remove("hidden");
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "plan-panel";
|
||||
el.dataset.requestId = requestId;
|
||||
|
||||
const isEmpty = !planContent || !planContent.trim();
|
||||
|
||||
if (isEmpty) {
|
||||
el.innerHTML = `
|
||||
<div class="plan-title">Exit plan mode?</div>
|
||||
<div class="plan-options">
|
||||
<button class="plan-option" data-value="yes-default" onclick="window._selectPlanOption(this, 'yes-default')">
|
||||
<span class="plan-option-label">Yes</span>
|
||||
</button>
|
||||
<button class="plan-option" data-value="no" onclick="window._selectPlanOption(this, 'no')">
|
||||
<span class="plan-option-label">No</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="plan-actions">
|
||||
<button class="btn-plan-submit" onclick="window._submitPlanResponse('${esc(requestId)}', this)">Submit</button>
|
||||
</div>`;
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div class="plan-title">Ready to code?</div>
|
||||
<div class="plan-content">${formatAssistantContent(planContent)}</div>
|
||||
<div class="plan-options">
|
||||
<button class="plan-option" data-value="yes-accept-edits" onclick="window._selectPlanOption(this, 'yes-accept-edits')">
|
||||
<span class="plan-option-label">Yes, auto-accept edits</span>
|
||||
<span class="plan-option-desc">Approve plan and auto-accept file edits</span>
|
||||
</button>
|
||||
<button class="plan-option" data-value="yes-default" onclick="window._selectPlanOption(this, 'yes-default')">
|
||||
<span class="plan-option-label">Yes, manually approve edits</span>
|
||||
<span class="plan-option-desc">Approve plan but confirm each edit</span>
|
||||
</button>
|
||||
<button class="plan-option" data-value="no" onclick="window._selectPlanOption(this, 'no')">
|
||||
<span class="plan-option-label">No, keep planning</span>
|
||||
<span class="plan-option-desc">Provide feedback to refine the plan</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="plan-feedback-area" data-for="no">
|
||||
<textarea class="plan-feedback-input" placeholder="Tell Claude what to change..."></textarea>
|
||||
</div>
|
||||
<div class="plan-actions">
|
||||
<button class="btn-plan-submit" onclick="window._submitPlanResponse('${esc(requestId)}', this)">Submit</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
area.appendChild(el);
|
||||
|
||||
el._selectedValue = null;
|
||||
el._planContent = planContent;
|
||||
el._isEmpty = isEmpty;
|
||||
|
||||
return renderSystemMessage("Waiting for your response...");
|
||||
}
|
||||
|
||||
function renderSystemMessage(text) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row system";
|
||||
row.innerHTML = `<div class="msg-bubble">${esc(text)}</div>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Loading Indicator — TUI star spinner style
|
||||
// ============================================================
|
||||
|
||||
const LOADING_ID = "loading-indicator";
|
||||
|
||||
// TUI star spinner frames (same as Claude Code CLI)
|
||||
const SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽"];
|
||||
const SPINNER_CYCLE = [...SPINNER_FRAMES, ...SPINNER_FRAMES.slice().reverse()];
|
||||
|
||||
// 204 verbs from TUI src/constants/spinnerVerbs.ts
|
||||
const SPINNER_VERBS = [
|
||||
"Accomplishing","Actioning","Actualizing","Architecting","Baking","Beaming",
|
||||
"Beboppin'","Befuddling","Billowing","Blanching","Bloviating","Boogieing",
|
||||
"Boondoggling","Booping","Bootstrapping","Brewing","Bunning","Burrowing",
|
||||
"Calculating","Canoodling","Caramelizing","Cascading","Catapulting","Cerebrating",
|
||||
"Channeling","Channelling","Choreographing","Churning","Clauding","Coalescing",
|
||||
"Cogitating","Combobulating","Composing","Computing","Concocting","Considering",
|
||||
"Contemplating","Cooking","Crafting","Creating","Crunching","Crystallizing",
|
||||
"Cultivating","Deciphering","Deliberating","Determining","Dilly-dallying",
|
||||
"Discombobulating","Doing","Doodling","Drizzling","Ebbing","Effecting",
|
||||
"Elucidating","Embellishing","Enchanting","Envisioning","Evaporating",
|
||||
"Fermenting","Fiddle-faddling","Finagling","Flambéing","Flibbertigibbeting",
|
||||
"Flowing","Flummoxing","Fluttering","Forging","Forming","Frolicking","Frosting",
|
||||
"Gallivanting","Galloping","Garnishing","Generating","Gesticulating",
|
||||
"Germinating","Gitifying","Grooving","Gusting","Harmonizing","Hashing",
|
||||
"Hatching","Herding","Honking","Hullaballooing","Hyperspacing","Ideating",
|
||||
"Imagining","Improvising","Incubating","Inferring","Infusing","Ionizing",
|
||||
"Jitterbugging","Julienning","Kneading","Leavening","Levitating","Lollygagging",
|
||||
"Manifesting","Marinating","Meandering","Metamorphosing","Misting","Moonwalking",
|
||||
"Moseying","Mulling","Mustering","Musing","Nebulizing","Nesting","Newspapering",
|
||||
"Noodling","Nucleating","Orbiting","Orchestrating","Osmosing","Perambulating",
|
||||
"Percolating","Perusing","Philosophising","Photosynthesizing","Pollinating",
|
||||
"Pondering","Pontificating","Pouncing","Precipitating","Prestidigitating",
|
||||
"Processing","Proofing","Propagating","Puttering","Puzzling","Quantumizing",
|
||||
"Razzle-dazzling","Razzmatazzing","Recombobulating","Reticulating","Roosting",
|
||||
"Ruminating","Sautéing","Scampering","Schlepping","Scurrying","Seasoning",
|
||||
"Shenaniganing","Shimmying","Simmering","Skedaddling","Sketching","Slithering",
|
||||
"Smooshing","Sock-hopping","Spelunking","Spinning","Sprouting","Stewing",
|
||||
"Sublimating","Swirling","Swooping","Symbioting","Synthesizing","Tempering",
|
||||
"Thinking","Thundering","Tinkering","Tomfoolering","Topsy-turvying",
|
||||
"Transfiguring","Transmuting","Twisting","Undulating","Unfurling","Unravelling",
|
||||
"Vibing","Waddling","Wandering","Warping","Whatchamacalliting","Whirlpooling",
|
||||
"Whirring","Whisking","Wibbling","Working","Wrangling","Zesting","Zigzagging",
|
||||
];
|
||||
|
||||
// Animation state
|
||||
let spinnerInterval = null;
|
||||
let timerInterval = null;
|
||||
let stalledCheckInterval = null;
|
||||
let spinnerFrame = 0;
|
||||
let loadingStartTime = 0;
|
||||
let lastActivityTime = 0;
|
||||
let isStalled = false;
|
||||
let loadingActive = false;
|
||||
|
||||
export function isLoading() {
|
||||
return loadingActive;
|
||||
}
|
||||
|
||||
function syncActionBtn(state) {
|
||||
if (typeof window.__updateActionBtn === "function") window.__updateActionBtn(state);
|
||||
}
|
||||
|
||||
export function showLoading() {
|
||||
removeLoading();
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
|
||||
loadingActive = true;
|
||||
syncActionBtn(true);
|
||||
|
||||
const verb = SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)];
|
||||
loadingStartTime = Date.now();
|
||||
lastActivityTime = Date.now();
|
||||
isStalled = false;
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.id = LOADING_ID;
|
||||
el.className = "msg-row loading-row";
|
||||
el.innerHTML = `<span class="tui-spinner">${SPINNER_CYCLE[0]}</span><span class="tui-verb glimmer-text">${esc(verb)}…</span><span class="tui-timer">0s</span>`;
|
||||
stream.appendChild(el);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
|
||||
const spinnerEl = el.querySelector(".tui-spinner");
|
||||
const timerEl = el.querySelector(".tui-timer");
|
||||
const loadingEl = el;
|
||||
|
||||
// Spinner animation — 120ms interval, same as TUI
|
||||
spinnerFrame = 0;
|
||||
spinnerInterval = setInterval(() => {
|
||||
spinnerFrame = (spinnerFrame + 1) % SPINNER_CYCLE.length;
|
||||
if (spinnerEl) spinnerEl.textContent = SPINNER_CYCLE[spinnerFrame];
|
||||
}, 120);
|
||||
|
||||
// Timer — update every second
|
||||
timerInterval = setInterval(() => {
|
||||
if (timerEl) {
|
||||
const elapsed = Math.floor((Date.now() - loadingStartTime) / 1000);
|
||||
timerEl.textContent = `${elapsed}s`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Stalled detection — check every 120ms (aligned with spinner)
|
||||
stalledCheckInterval = setInterval(() => {
|
||||
if (!isStalled && Date.now() - lastActivityTime > 3000) {
|
||||
isStalled = true;
|
||||
if (loadingEl) loadingEl.classList.add("stalled");
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
export function removeLoading() {
|
||||
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
|
||||
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
||||
if (stalledCheckInterval) { clearInterval(stalledCheckInterval); stalledCheckInterval = null; }
|
||||
isStalled = false;
|
||||
loadingActive = false;
|
||||
syncActionBtn(false);
|
||||
const el = document.getElementById(LOADING_ID);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
/** Reset stalled timer — call when SSE events arrive */
|
||||
export function refreshLoadingActivity() {
|
||||
lastActivityTime = Date.now();
|
||||
if (isStalled) {
|
||||
isStalled = false;
|
||||
const loadingEl = document.getElementById(LOADING_ID);
|
||||
if (loadingEl) loadingEl.classList.remove("stalled");
|
||||
}
|
||||
}
|
||||
53
packages/remote-control-server/web/sse.js
Normal file
53
packages/remote-control-server/web/sse.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Remote Control — SSE Connection Manager (UUID-based auth)
|
||||
*/
|
||||
import { getUuid } from "./api.js";
|
||||
import { refreshLoadingActivity } from "./render.js";
|
||||
|
||||
let currentEventSource = null;
|
||||
let currentSSESessionId = null;
|
||||
let onEventCallback = null;
|
||||
|
||||
export function connectSSE(sessionId, onEvent, fromSeqNum = 0) {
|
||||
disconnectSSE();
|
||||
currentSSESessionId = sessionId;
|
||||
onEventCallback = onEvent;
|
||||
|
||||
const uuid = getUuid();
|
||||
let url = `/web/sessions/${sessionId}/events?uuid=${encodeURIComponent(uuid)}`;
|
||||
|
||||
const es = new EventSource(url);
|
||||
currentEventSource = es;
|
||||
|
||||
// Track the last sequence number we've seen to avoid duplicates
|
||||
let lastSeenSeq = fromSeqNum;
|
||||
|
||||
es.addEventListener("message", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
// Skip events we've already rendered from history
|
||||
if (data.seqNum !== undefined && data.seqNum <= lastSeenSeq) return;
|
||||
if (data.seqNum !== undefined) lastSeenSeq = data.seqNum;
|
||||
onEventCallback?.(data);
|
||||
refreshLoadingActivity();
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
// EventSource auto-reconnects
|
||||
});
|
||||
}
|
||||
|
||||
export function disconnectSSE() {
|
||||
if (currentEventSource) {
|
||||
currentEventSource.close();
|
||||
currentEventSource = null;
|
||||
currentSSESessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentSSESessionId() {
|
||||
return currentSSESessionId;
|
||||
}
|
||||
6
packages/remote-control-server/web/style.css
Normal file
6
packages/remote-control-server/web/style.css
Normal file
@@ -0,0 +1,6 @@
|
||||
/* Main stylesheet — imports all modules */
|
||||
@import url('./base.css');
|
||||
@import url('./components.css');
|
||||
@import url('./pages.css');
|
||||
@import url('./messages.css');
|
||||
@import url('./task-panel.css');
|
||||
275
packages/remote-control-server/web/task-panel.css
Normal file
275
packages/remote-control-server/web/task-panel.css
Normal file
@@ -0,0 +1,275 @@
|
||||
/* === Task/Todo Floating Panel — Anthropic === */
|
||||
|
||||
/* Panel container */
|
||||
.task-panel {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 56px;
|
||||
bottom: 0;
|
||||
width: 340px;
|
||||
background: var(--bg-card);
|
||||
border-left: 1px solid var(--border-light);
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.06);
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: transform var(--transition-base, 0.2s) ease, opacity var(--transition-base, 0.2s) ease;
|
||||
}
|
||||
.task-panel.hidden {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.task-panel.visible {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Main content shifts left when panel is open */
|
||||
.session-container.panel-open {
|
||||
margin-right: 340px;
|
||||
transition: margin-right var(--transition-base, 0.2s) ease;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.tp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tp-title {
|
||||
font-family: var(--font-display, "Bricolage Grotesque", sans-serif);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tp-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
transition: color var(--transition-fast, 0.12s);
|
||||
}
|
||||
.tp-close-btn:hover { color: var(--text-primary); }
|
||||
|
||||
/* Scrollable body */
|
||||
.tp-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.tp-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.tp-progress {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
background: var(--bg-input, #f5f1eb);
|
||||
margin: 12px 16px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tp-progress-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--green, #3b8a6a);
|
||||
border-radius: 6px;
|
||||
transition: width 0.3s ease;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.tp-progress-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.tp-section {
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
.tp-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px 6px;
|
||||
}
|
||||
.tp-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tp-section-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tp-stat-dim {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.tp-section-body {
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
|
||||
/* Task/Todo item */
|
||||
.tp-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 6px;
|
||||
border-radius: 6px;
|
||||
transition: background var(--transition-fast, 0.12s);
|
||||
}
|
||||
.tp-item:hover {
|
||||
background: var(--bg-input, #f5f1eb);
|
||||
}
|
||||
|
||||
/* Status icon */
|
||||
.tp-item-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.tp-icon-done {
|
||||
color: var(--green, #3b8a6a);
|
||||
}
|
||||
.tp-icon-active {
|
||||
color: var(--accent, #d97757);
|
||||
animation: tpPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.tp-icon-pending {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tp-icon-deleted {
|
||||
color: var(--red, #c83c3c);
|
||||
}
|
||||
|
||||
@keyframes tpPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Item content */
|
||||
.tp-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tp-item-subject {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
.tp-status-completed .tp-item-subject {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tp-status-deleted .tp-item-subject {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tp-blocked .tp-item-subject {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Active form (spinner text) */
|
||||
.tp-item-active {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent, #d97757);
|
||||
margin-top: 2px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Blocked indicator */
|
||||
.tp-item-blocked {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
font-family: var(--font-mono, "Fira Code", monospace);
|
||||
}
|
||||
|
||||
/* Owner badge */
|
||||
.tp-item-owner {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-input, #f5f1eb);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Toggle badge in nav */
|
||||
.task-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-light, #fff);
|
||||
background: var(--accent, #d97757);
|
||||
border-radius: 9px;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.task-count-badge.hidden { display: none; }
|
||||
|
||||
/* Active toggle button state */
|
||||
#task-panel-toggle.active {
|
||||
color: var(--accent, #d97757);
|
||||
background: rgba(217, 119, 87, 0.08);
|
||||
}
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 640px) {
|
||||
.task-panel {
|
||||
width: 100%;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
.session-container.panel-open {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
400
packages/remote-control-server/web/task-panel.js
Normal file
400
packages/remote-control-server/web/task-panel.js
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Remote Control — Task/Todo Floating Panel
|
||||
*
|
||||
* Parses tool_use blocks from assistant events to extract TaskCreate,
|
||||
* TaskUpdate, and TodoWrite operations, then renders a floating panel
|
||||
* showing the current task/todo state.
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
|
||||
/** @type {Map<string, TaskItem>} V2 Tasks keyed by id */
|
||||
const tasks = new Map();
|
||||
|
||||
/** @type {TodoItem[]} V1 Todos */
|
||||
let todos = [];
|
||||
|
||||
/** @type {boolean} Panel visibility */
|
||||
let panelVisible = false;
|
||||
|
||||
/** @type {HTMLElement|null} Panel root element */
|
||||
let panelEl = null;
|
||||
|
||||
/** @type {HTMLElement|null} Badge element showing count */
|
||||
let badgeEl = null;
|
||||
|
||||
// ============================================================
|
||||
// Types (JSDoc for clarity)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* @typedef {Object} TaskItem
|
||||
* @property {string} id
|
||||
* @property {string} subject
|
||||
* @property {string} description
|
||||
* @property {string} [activeForm]
|
||||
* @property {'pending'|'in_progress'|'completed'|'deleted'} status
|
||||
* @property {string} [owner]
|
||||
* @property {string[]} blocks
|
||||
* @property {string[]} blockedBy
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TodoItem
|
||||
* @property {string} content
|
||||
* @property {'pending'|'in_progress'|'completed'} status
|
||||
* @property {string} activeForm
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// State mutations
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Process an assistant event payload, extracting tool_use blocks.
|
||||
* @param {{ message?: { content?: unknown } }} payload
|
||||
*/
|
||||
export function processAssistantEvent(payload) {
|
||||
if (!payload || !payload.message) return;
|
||||
|
||||
const content = payload.message.content;
|
||||
if (!Array.isArray(content)) return;
|
||||
|
||||
let changed = false;
|
||||
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object" || block.type !== "tool_use") continue;
|
||||
|
||||
const name = block.name;
|
||||
const input = block.input || {};
|
||||
|
||||
if (name === "TaskCreate") {
|
||||
handleTaskCreate(input);
|
||||
changed = true;
|
||||
} else if (name === "TaskUpdate") {
|
||||
handleTaskUpdate(input);
|
||||
changed = true;
|
||||
} else if (name === "TodoWrite") {
|
||||
handleTodoWrite(input);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
renderPanel();
|
||||
updateBadge();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ subject?: string, description?: string, activeForm?: string, metadata?: object }} input
|
||||
*/
|
||||
function handleTaskCreate(input) {
|
||||
// TaskCreate creates a task; the tool itself generates the ID server-side.
|
||||
// We extract from the tool output (tool_result) if available, or use a
|
||||
// synthetic ID. The actual ID comes from the tool result event.
|
||||
// Since we only see tool_use (not tool_result here), we create with a
|
||||
// temporary key based on subject and let TaskUpdate resolve it.
|
||||
const subject = input.subject || "Untitled task";
|
||||
const description = input.description || "";
|
||||
const activeForm = input.activeForm;
|
||||
|
||||
// Check if there's an id in the input (some versions include it)
|
||||
const id = input.taskId || input.id || `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
tasks.set(id, {
|
||||
id,
|
||||
subject,
|
||||
description,
|
||||
activeForm,
|
||||
status: "pending",
|
||||
owner: undefined,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ taskId?: string, status?: string, subject?: string, description?: string, activeForm?: string, owner?: string, addBlocks?: string[], addBlockedBy?: string[], metadata?: object }} input
|
||||
*/
|
||||
function handleTaskUpdate(input) {
|
||||
const id = input.taskId;
|
||||
if (!id) return;
|
||||
|
||||
const existing = tasks.get(id);
|
||||
if (!existing) {
|
||||
// Task wasn't tracked yet — create it from the update
|
||||
tasks.set(id, {
|
||||
id,
|
||||
subject: input.subject || "Untitled task",
|
||||
description: input.description || "",
|
||||
activeForm: input.activeForm,
|
||||
status: input.status || "pending",
|
||||
owner: input.owner,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.subject !== undefined) existing.subject = input.subject;
|
||||
if (input.description !== undefined) existing.description = input.description;
|
||||
if (input.activeForm !== undefined) existing.activeForm = input.activeForm;
|
||||
if (input.status !== undefined) existing.status = input.status;
|
||||
if (input.owner !== undefined) existing.owner = input.owner;
|
||||
if (input.addBlocks) {
|
||||
existing.blocks = [...new Set([...existing.blocks, ...input.addBlocks])];
|
||||
}
|
||||
if (input.addBlockedBy) {
|
||||
existing.blockedBy = [...new Set([...existing.blockedBy, ...input.addBlockedBy])];
|
||||
}
|
||||
if (input.status === "deleted") {
|
||||
tasks.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ todos?: Array<{ content: string, status: string, activeForm: string }> }} input
|
||||
*/
|
||||
function handleTodoWrite(input) {
|
||||
if (!Array.isArray(input.todos)) return;
|
||||
todos = input.todos.map((t) => ({
|
||||
content: t.content || "",
|
||||
status: t.status || "pending",
|
||||
activeForm: t.activeForm || "",
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Reset all state (call when switching sessions).
|
||||
*/
|
||||
export function resetTaskState() {
|
||||
tasks.clear();
|
||||
todos = [];
|
||||
if (panelEl) panelEl.innerHTML = "";
|
||||
updateBadge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state for debugging.
|
||||
*/
|
||||
export function getTaskState() {
|
||||
return { tasks: [...tasks.values()], todos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the task panel DOM.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
export function initTaskPanel(container) {
|
||||
if (panelEl) return; // already initialized
|
||||
panelEl = container;
|
||||
badgeEl = document.getElementById("task-badge");
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle panel visibility.
|
||||
*/
|
||||
export function toggleTaskPanel() {
|
||||
panelVisible = !panelVisible;
|
||||
if (panelEl) {
|
||||
panelEl.classList.toggle("hidden", !panelVisible);
|
||||
panelEl.classList.toggle("visible", panelVisible);
|
||||
}
|
||||
// Adjust main content margin
|
||||
const sessionContainer = document.querySelector(".session-container");
|
||||
if (sessionContainer) {
|
||||
sessionContainer.classList.toggle("panel-open", panelVisible);
|
||||
}
|
||||
// Toggle active state on the nav button
|
||||
const toggleBtn = document.getElementById("task-panel-toggle");
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.toggle("active", panelVisible);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the panel.
|
||||
*/
|
||||
export function showTaskPanel() {
|
||||
if (!panelVisible) toggleTaskPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the panel.
|
||||
*/
|
||||
export function hideTaskPanel() {
|
||||
if (panelVisible) toggleTaskPanel();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Rendering
|
||||
// ============================================================
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return "";
|
||||
const d = document.createElement("div");
|
||||
d.textContent = String(str);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
if (!panelEl) return;
|
||||
|
||||
const allTasks = [...tasks.values()];
|
||||
const hasTasks = allTasks.length > 0;
|
||||
const hasTodos = todos.length > 0;
|
||||
|
||||
if (!hasTasks && !hasTodos) {
|
||||
panelEl.innerHTML = `<div class="tp-empty">No tasks or todos yet</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
|
||||
// Progress summary
|
||||
const totalItems = allTasks.length + todos.length;
|
||||
const completedTasks = allTasks.filter((t) => t.status === "completed").length;
|
||||
const completedTodos = todos.filter((t) => t.status === "completed").length;
|
||||
const completedTotal = completedTasks + completedTodos;
|
||||
const pct = totalItems > 0 ? Math.round((completedTotal / totalItems) * 100) : 0;
|
||||
|
||||
parts.push(`
|
||||
<div class="tp-progress">
|
||||
<div class="tp-progress-bar" style="width:${pct}%"></div>
|
||||
<span class="tp-progress-label">${completedTotal}/${totalItems} completed</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// V2 Tasks section
|
||||
if (hasTasks) {
|
||||
const inProgress = allTasks.filter((t) => t.status === "in_progress").length;
|
||||
const pending = allTasks.filter((t) => t.status === "pending").length;
|
||||
const completed = allTasks.filter((t) => t.status === "completed").length;
|
||||
parts.push(`
|
||||
<div class="tp-section">
|
||||
<div class="tp-section-header">
|
||||
<span class="tp-section-title">Tasks</span>
|
||||
<span class="tp-section-stats">
|
||||
${completed}<span class="tp-stat-dim">done</span>
|
||||
${inProgress > 0 ? `${inProgress}<span class="tp-stat-dim">active</span>` : ""}
|
||||
${pending > 0 ? `${pending}<span class="tp-stat-dim">open</span>` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tp-section-body">
|
||||
${allTasks.map(renderTaskItem).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// V1 Todos section
|
||||
if (hasTodos) {
|
||||
const inProgress = todos.filter((t) => t.status === "in_progress").length;
|
||||
const pending = todos.filter((t) => t.status === "pending").length;
|
||||
const completed = todos.filter((t) => t.status === "completed").length;
|
||||
parts.push(`
|
||||
<div class="tp-section">
|
||||
<div class="tp-section-header">
|
||||
<span class="tp-section-title">Todos</span>
|
||||
<span class="tp-section-stats">
|
||||
${completed}<span class="tp-stat-dim">done</span>
|
||||
${inProgress > 0 ? `${inProgress}<span class="tp-stat-dim">active</span>` : ""}
|
||||
${pending > 0 ? `${pending}<span class="tp-stat-dim">open</span>` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tp-section-body">
|
||||
${todos.map(renderTodoItem).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
panelEl.innerHTML = `
|
||||
<div class="tp-header">
|
||||
<span class="tp-title">Tasks & Todos</span>
|
||||
<button class="tp-close-btn" onclick="window.__toggleTaskPanel()">×</button>
|
||||
</div>
|
||||
<div class="tp-body">${parts.join("")}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TaskItem} task
|
||||
*/
|
||||
function renderTaskItem(task) {
|
||||
const icon = statusIcon(task.status);
|
||||
const isBlocked = task.blockedBy.length > 0 && task.status !== "completed";
|
||||
const cls = [
|
||||
"tp-item",
|
||||
`tp-status-${task.status}`,
|
||||
isBlocked ? "tp-blocked" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return `
|
||||
<div class="${cls}">
|
||||
<span class="tp-item-icon ${icon.cls}">${icon.char}</span>
|
||||
<div class="tp-item-content">
|
||||
<div class="tp-item-subject">${esc(task.subject)}</div>
|
||||
${task.activeForm && task.status === "in_progress" ? `<div class="tp-item-active">${esc(task.activeForm)}...</div>` : ""}
|
||||
${isBlocked ? `<div class="tp-item-blocked">blocked by ${task.blockedBy.map((id) => `#${esc(id)}`).join(", ")}</div>` : ""}
|
||||
</div>
|
||||
${task.owner ? `<span class="tp-item-owner">@${esc(task.owner)}</span>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TodoItem} todo
|
||||
*/
|
||||
function renderTodoItem(todo) {
|
||||
const icon = statusIcon(todo.status);
|
||||
const cls = ["tp-item", `tp-status-${todo.status}`].join(" ");
|
||||
|
||||
return `
|
||||
<div class="${cls}">
|
||||
<span class="tp-item-icon ${icon.cls}">${icon.char}</span>
|
||||
<div class="tp-item-content">
|
||||
<div class="tp-item-subject">${esc(todo.content)}</div>
|
||||
${todo.activeForm && todo.status === "in_progress" ? `<div class="tp-item-active">${esc(todo.activeForm)}...</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} status
|
||||
* @returns {{ char: string, cls: string }}
|
||||
*/
|
||||
function statusIcon(status) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return { char: "\u2713", cls: "tp-icon-done" };
|
||||
case "in_progress":
|
||||
return { char: "\u25CF", cls: "tp-icon-active" };
|
||||
case "deleted":
|
||||
return { char: "\u2717", cls: "tp-icon-deleted" };
|
||||
default:
|
||||
return { char: "\u25CB", cls: "tp-icon-pending" };
|
||||
}
|
||||
}
|
||||
|
||||
function updateBadge() {
|
||||
if (!badgeEl) return;
|
||||
const total = tasks.size + todos.length;
|
||||
if (total > 0) {
|
||||
badgeEl.textContent = String(total);
|
||||
badgeEl.classList.remove("hidden");
|
||||
} else {
|
||||
badgeEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
27
packages/remote-control-server/web/utils.js
Normal file
27
packages/remote-control-server/web/utils.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Remote Control — Shared Utilities
|
||||
*/
|
||||
|
||||
export function esc(str) {
|
||||
if (!str) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function formatTime(ts) {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export function statusClass(status) {
|
||||
const map = {
|
||||
active: "active",
|
||||
running: "running",
|
||||
idle: "idle",
|
||||
requires_action: "requires_action",
|
||||
archived: "archived",
|
||||
error: "error",
|
||||
};
|
||||
return map[status] || "default";
|
||||
}
|
||||
Reference in New Issue
Block a user