/** * 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 `
${code.trim()}`;
});
// Inline code: `...`
html = html.replace(/`([^`]+)`/g, '$1');
// Bold: **...**
html = html.replace(/\*\*([^*]+)\*\*/g, "$1");
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 = `