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:
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user