mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
This commit is contained in:
@@ -4,8 +4,24 @@
|
||||
*/
|
||||
import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js";
|
||||
import { connectSSE, disconnectSSE } from "./sse.js";
|
||||
import { appendEvent, showLoading, isLoading, removeLoading, resetReplayState, renderReplayPendingRequests } from "./render.js";
|
||||
import {
|
||||
appendEvent,
|
||||
getActivityMode,
|
||||
removeLoading,
|
||||
resetReplayState,
|
||||
renderReplayPendingRequests,
|
||||
setAutomationActivity,
|
||||
showLoading,
|
||||
} from "./render.js";
|
||||
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
|
||||
import {
|
||||
createAutomationState,
|
||||
getAutomationActivity,
|
||||
getAutomationIndicator,
|
||||
reduceAutomationState,
|
||||
renderAutomationIcon,
|
||||
shouldPulseAutomationIndicator,
|
||||
} from "./automation.js";
|
||||
import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js";
|
||||
|
||||
// ============================================================
|
||||
@@ -16,6 +32,8 @@ let currentSessionId = null;
|
||||
let currentSessionStatus = null;
|
||||
let dashboardInterval = null;
|
||||
let cachedEnvs = [];
|
||||
let automationState = createAutomationState();
|
||||
let automationPulseTimer = null;
|
||||
|
||||
function generateMessageUuid() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
@@ -24,6 +42,82 @@ function generateMessageUuid() {
|
||||
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function renderAutomationIndicator() {
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (!indicatorEl) return;
|
||||
|
||||
const indicator = getAutomationIndicator(automationState);
|
||||
if (!indicator.visible) {
|
||||
indicatorEl.className = "automation-pill hidden";
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.innerHTML = "";
|
||||
indicatorEl.removeAttribute("title");
|
||||
return;
|
||||
}
|
||||
|
||||
indicatorEl.className = `automation-pill automation-pill-${indicator.tone}`;
|
||||
if (indicatorEl.dataset.pulsing === "true") {
|
||||
indicatorEl.classList.add("is-pulsing");
|
||||
}
|
||||
indicatorEl.innerHTML = `
|
||||
${renderAutomationIcon(indicator.iconVariant, { className: "automation-pill-icon" })}
|
||||
<span class="automation-pill-label">${esc(indicator.label)}</span>
|
||||
`;
|
||||
indicatorEl.title = indicator.title;
|
||||
}
|
||||
|
||||
function syncAutomationUI() {
|
||||
renderAutomationIndicator();
|
||||
setAutomationActivity(getAutomationActivity(automationState));
|
||||
}
|
||||
|
||||
function stopAutomationPulse() {
|
||||
if (automationPulseTimer) {
|
||||
clearTimeout(automationPulseTimer);
|
||||
automationPulseTimer = null;
|
||||
}
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (indicatorEl) {
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.classList.remove("is-pulsing");
|
||||
}
|
||||
}
|
||||
|
||||
function pulseAutomationIndicator() {
|
||||
if (!getAutomationIndicator(automationState).visible) return;
|
||||
|
||||
stopAutomationPulse();
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (!indicatorEl) return;
|
||||
|
||||
indicatorEl.dataset.pulsing = "true";
|
||||
indicatorEl.classList.add("is-pulsing");
|
||||
automationPulseTimer = setTimeout(() => {
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.classList.remove("is-pulsing");
|
||||
automationPulseTimer = null;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function resetAutomationIndicator() {
|
||||
automationState = createAutomationState();
|
||||
stopAutomationPulse();
|
||||
syncAutomationUI();
|
||||
}
|
||||
|
||||
function applyAutomationEvent(event, { replay = false } = {}) {
|
||||
automationState = reduceAutomationState(automationState, event);
|
||||
syncAutomationUI();
|
||||
if (!replay && shouldPulseAutomationIndicator(event)) {
|
||||
pulseAutomationIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
function applyAutomationSnapshot(snapshot) {
|
||||
if (snapshot === undefined) return;
|
||||
applyAutomationEvent({ type: "automation_state", payload: snapshot }, { replay: true });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Router
|
||||
// ============================================================
|
||||
@@ -75,7 +169,7 @@ function applySessionStatus(status) {
|
||||
|
||||
if (closed) {
|
||||
removeLoading();
|
||||
window.__updateActionBtn?.(false);
|
||||
window.__updateActionBtn?.("idle");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +180,7 @@ function handleSessionEvent(event) {
|
||||
disconnectSSE();
|
||||
}
|
||||
}
|
||||
applyAutomationEvent(event);
|
||||
appendEvent(event);
|
||||
}
|
||||
|
||||
@@ -104,7 +199,9 @@ async function syncClosedSessionState(err, actionLabel) {
|
||||
const session = await apiFetchSession(currentSessionId);
|
||||
applySessionStatus(session.status);
|
||||
if (isClosedSessionStatus(session.status)) {
|
||||
appendEvent({ type: "session_status", payload: { status: session.status } });
|
||||
const closedEvent = { type: "session_status", payload: { status: session.status } };
|
||||
applyAutomationEvent(closedEvent);
|
||||
appendEvent(closedEvent);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -159,6 +256,7 @@ async function handleRoute() {
|
||||
// Default: /code → dashboard
|
||||
currentSessionId = null;
|
||||
currentSessionStatus = null;
|
||||
resetAutomationIndicator();
|
||||
showPage("dashboard");
|
||||
disconnectSSE();
|
||||
renderDashboard();
|
||||
@@ -233,6 +331,8 @@ function stopDashboardRefresh() {
|
||||
|
||||
async function renderSessionDetail(id) {
|
||||
currentSessionId = id;
|
||||
resetAutomationIndicator();
|
||||
let session = null;
|
||||
|
||||
// Reset task state for new session and init panel
|
||||
resetTaskState();
|
||||
@@ -240,7 +340,7 @@ async function renderSessionDetail(id) {
|
||||
if (taskPanelEl) initTaskPanel(taskPanelEl);
|
||||
|
||||
try {
|
||||
const session = await apiFetchSession(id);
|
||||
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 || "";
|
||||
@@ -254,6 +354,7 @@ async function renderSessionDetail(id) {
|
||||
document.getElementById("event-stream").innerHTML = "";
|
||||
document.getElementById("permission-area").innerHTML = "";
|
||||
document.getElementById("permission-area").classList.add("hidden");
|
||||
applyAutomationSnapshot(session?.automation_state);
|
||||
|
||||
// Load historical events before connecting to live stream
|
||||
resetReplayState();
|
||||
@@ -262,6 +363,7 @@ async function renderSessionDetail(id) {
|
||||
const { events } = await apiFetchSessionHistory(id);
|
||||
if (events && events.length > 0) {
|
||||
for (const event of events) {
|
||||
applyAutomationEvent(event, { replay: true });
|
||||
appendEvent(event, { replay: true });
|
||||
if (event.seqNum && event.seqNum > lastSeqNum) lastSeqNum = event.seqNum;
|
||||
}
|
||||
@@ -273,7 +375,9 @@ async function renderSessionDetail(id) {
|
||||
renderReplayPendingRequests();
|
||||
|
||||
if (isClosedSessionStatus(currentSessionStatus)) {
|
||||
appendEvent({ type: "session_status", payload: { status: currentSessionStatus } });
|
||||
const closedEvent = { type: "session_status", payload: { status: currentSessionStatus } };
|
||||
applyAutomationEvent(closedEvent);
|
||||
appendEvent(closedEvent);
|
||||
disconnectSSE();
|
||||
return;
|
||||
}
|
||||
@@ -291,17 +395,20 @@ function setupControlBar() {
|
||||
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);
|
||||
function setBtnState(mode) {
|
||||
const working = mode === "working";
|
||||
actionBtn.classList.toggle("loading", working);
|
||||
actionBtn.dataset.mode = mode || "idle";
|
||||
actionBtn.setAttribute("aria-label", working ? "Stop" : "Send");
|
||||
iconSend.classList.toggle("hidden", working);
|
||||
iconStop.classList.toggle("hidden", !working);
|
||||
}
|
||||
|
||||
window.__updateActionBtn = setBtnState;
|
||||
setBtnState(getActivityMode());
|
||||
|
||||
actionBtn.addEventListener("click", () => {
|
||||
if (isLoading()) {
|
||||
if (getActivityMode() === "working") {
|
||||
doInterrupt();
|
||||
} else {
|
||||
sendMessage();
|
||||
@@ -319,7 +426,6 @@ async function doInterrupt() {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiInterrupt(currentSessionId);
|
||||
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
|
||||
} catch (err) {
|
||||
await syncClosedSessionState(err, "Interrupt failed");
|
||||
} finally {
|
||||
@@ -460,11 +566,28 @@ window._submitAnswers = async function (requestId, btn) {
|
||||
|
||||
function removePermissionPrompt(btn) {
|
||||
const prompt = btn.closest(".permission-prompt, .ask-panel, .plan-panel");
|
||||
const requestId = prompt?.dataset?.requestId || null;
|
||||
if (prompt) prompt.remove();
|
||||
if (requestId) {
|
||||
const stream = document.getElementById("event-stream");
|
||||
stream?.querySelectorAll("[data-pending-request-id]").forEach((row) => {
|
||||
if (row.dataset.pendingRequestId === requestId) row.remove();
|
||||
});
|
||||
}
|
||||
const area = document.getElementById("permission-area");
|
||||
if (area && area.children.length === 0) area.classList.add("hidden");
|
||||
}
|
||||
|
||||
function appendLocalSystemMessage(text) {
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row system";
|
||||
row.innerHTML = `<div class="msg-bubble">${esc(text)}</div>`;
|
||||
stream.appendChild(row);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ExitPlanMode interactions
|
||||
// ============================================================
|
||||
@@ -509,6 +632,7 @@ window._submitPlanResponse = async function (requestId, btn) {
|
||||
...(feedback ? { message: feedback } : {}),
|
||||
});
|
||||
removePermissionPrompt(btn);
|
||||
appendLocalSystemMessage("Feedback sent. Continuing in plan mode.");
|
||||
} else {
|
||||
// Approval with permission mode
|
||||
const modeMap = {
|
||||
|
||||
Reference in New Issue
Block a user