rspace-online/lib/mi-triage-panel.ts

343 lines
9.6 KiB
TypeScript

/**
* MI Triage Panel — floating preview UI for content triage proposals.
*
* Shows the AI summary, proposed shapes as cards with remove buttons,
* connections, and Create All / Cancel actions.
* Uses existing --rs-* CSS variables for theming.
*/
import type { TriageManager } from "./mi-content-triage";
/** Icon lookup by tagName — matches TOOL_HINTS from mi-tool-schema.ts */
const SHAPE_ICONS: Record<string, { icon: string; label: string }> = {
"folk-markdown": { icon: "📝", label: "Note" },
"folk-embed": { icon: "🔗", label: "Embed" },
"folk-calendar": { icon: "📅", label: "Calendar" },
"folk-map": { icon: "🗺️", label: "Map" },
"folk-workflow-block": { icon: "⚙️", label: "Workflow" },
"folk-social-post": { icon: "📣", label: "Social Post" },
"folk-choice-vote": { icon: "🗳️", label: "Vote" },
"folk-prompt": { icon: "🤖", label: "AI Chat" },
"folk-image-gen": { icon: "🎨", label: "AI Image" },
"folk-slide": { icon: "🖼️", label: "Slide" },
};
export class MiTriagePanel {
private el: HTMLDivElement;
private manager: TriageManager;
constructor(manager: TriageManager) {
this.manager = manager;
this.el = document.createElement("div");
this.el.className = "mi-triage-panel";
this.el.innerHTML = this.renderLoading();
this.injectStyles();
document.body.appendChild(this.el);
// Re-render when manager state changes
this.manager = manager;
const originalOnChange = (manager as any).onChange;
(manager as any).onChange = () => {
originalOnChange?.();
this.render();
};
}
private render() {
switch (this.manager.status) {
case "analyzing":
this.el.innerHTML = this.renderLoading();
break;
case "ready":
this.el.innerHTML = this.renderProposal();
this.bindEvents();
break;
case "error":
this.el.innerHTML = this.renderError();
this.bindCloseEvent();
break;
default:
this.close();
}
}
private renderLoading(): string {
return `
<div class="mi-triage-header">
<span class="mi-triage-title">MI Content Triage</span>
</div>
<div class="mi-triage-body mi-triage-loading">
<div class="mi-triage-spinner"></div>
<p>Analyzing content...</p>
</div>
`;
}
private renderError(): string {
return `
<div class="mi-triage-header">
<span class="mi-triage-title">MI Content Triage</span>
<button class="mi-triage-close" data-action="close">&times;</button>
</div>
<div class="mi-triage-body mi-triage-error">
<p>${this.manager.error || "Something went wrong"}</p>
</div>
`;
}
private renderProposal(): string {
const p = this.manager.proposal!;
const shapeCards = p.shapes
.map((s, i) => {
const info = SHAPE_ICONS[s.tagName] || { icon: "📦", label: s.tagName };
return `
<div class="mi-triage-card" data-index="${i}">
<div class="mi-triage-card-header">
<span class="mi-triage-card-icon">${info.icon}</span>
<span class="mi-triage-card-label">${escapeHtml(s.label)}</span>
<span class="mi-triage-card-badge">${info.label}</span>
<button class="mi-triage-card-remove" data-action="remove" data-index="${i}">&times;</button>
</div>
<div class="mi-triage-card-snippet">${escapeHtml(s.snippet || "")}</div>
</div>`;
})
.join("");
const connList =
p.connections.length > 0
? `<div class="mi-triage-connections">
<div class="mi-triage-conn-title">Connections</div>
${p.connections
.map((c) => {
const from = p.shapes[c.fromIndex]?.label || `#${c.fromIndex}`;
const to = p.shapes[c.toIndex]?.label || `#${c.toIndex}`;
return `<div class="mi-triage-conn">${escapeHtml(from)}${escapeHtml(to)}<span class="mi-triage-conn-reason">${escapeHtml(c.reason || "")}</span></div>`;
})
.join("")}
</div>`
: "";
return `
<div class="mi-triage-header">
<span class="mi-triage-title">MI Content Triage</span>
<button class="mi-triage-close" data-action="close">&times;</button>
</div>
<div class="mi-triage-summary">${escapeHtml(p.summary)}</div>
<div class="mi-triage-body">
${shapeCards}
${connList}
</div>
<div class="mi-triage-footer">
<button class="mi-triage-btn mi-triage-btn-cancel" data-action="close">Cancel</button>
<button class="mi-triage-btn mi-triage-btn-commit" data-action="commit">Create All (${p.shapes.length} shape${p.shapes.length !== 1 ? "s" : ""})</button>
</div>
`;
}
private bindEvents() {
this.el.querySelectorAll("[data-action='remove']").forEach((btn) => {
btn.addEventListener("click", (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).dataset.index || "0");
this.manager.removeShape(idx);
});
});
this.el.querySelector("[data-action='commit']")?.addEventListener("click", () => {
this.manager.commitAll();
this.close();
});
this.bindCloseEvent();
}
private bindCloseEvent() {
this.el.querySelectorAll("[data-action='close']").forEach((btn) => {
btn.addEventListener("click", () => this.close());
});
}
close() {
this.el.remove();
}
private injectStyles() {
if (document.getElementById("mi-triage-styles")) return;
const style = document.createElement("style");
style.id = "mi-triage-styles";
style.textContent = `
.mi-triage-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 420px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
background: var(--rs-surface, #1e1e2e);
color: var(--rs-text, #cdd6f4);
border: 1px solid var(--rs-border, #45475a);
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
animation: mi-triage-in 0.2s ease-out;
}
@keyframes mi-triage-in {
from { opacity: 0; transform: translate(-50%, -48%); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
.mi-triage-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--rs-border, #45475a);
}
.mi-triage-title {
font-weight: 600;
font-size: 14px;
}
.mi-triage-close {
background: none;
border: none;
color: var(--rs-text-muted, #a6adc8);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.mi-triage-close:hover { color: var(--rs-text, #cdd6f4); }
.mi-triage-summary {
padding: 10px 16px;
font-size: 12px;
color: var(--rs-text-muted, #a6adc8);
border-bottom: 1px solid var(--rs-border, #45475a);
}
.mi-triage-body {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.mi-triage-loading {
align-items: center;
justify-content: center;
padding: 40px 16px;
}
.mi-triage-spinner {
width: 28px;
height: 28px;
border: 3px solid var(--rs-border, #45475a);
border-top-color: #14b8a6;
border-radius: 50%;
animation: mi-spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes mi-spin { to { transform: rotate(360deg); } }
.mi-triage-error {
align-items: center;
justify-content: center;
padding: 32px 16px;
color: #f38ba8;
}
.mi-triage-card {
background: var(--rs-card-bg, #313244);
border: 1px solid var(--rs-border, #45475a);
border-radius: 10px;
padding: 10px 12px;
}
.mi-triage-card-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.mi-triage-card-icon { font-size: 16px; }
.mi-triage-card-label {
flex: 1;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mi-triage-card-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 6px;
background: var(--rs-badge-bg, #45475a);
color: var(--rs-text-muted, #a6adc8);
white-space: nowrap;
}
.mi-triage-card-remove {
background: none;
border: none;
color: var(--rs-text-muted, #a6adc8);
font-size: 16px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.mi-triage-card-remove:hover { color: #f38ba8; }
.mi-triage-card-snippet {
font-size: 11px;
color: var(--rs-text-muted, #a6adc8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mi-triage-connections {
padding: 8px 0 0;
border-top: 1px solid var(--rs-border, #45475a);
}
.mi-triage-conn-title {
font-size: 11px;
font-weight: 600;
color: var(--rs-text-muted, #a6adc8);
margin-bottom: 4px;
}
.mi-triage-conn {
font-size: 11px;
padding: 2px 0;
color: var(--rs-text, #cdd6f4);
}
.mi-triage-conn-reason {
margin-left: 6px;
color: var(--rs-text-muted, #a6adc8);
}
.mi-triage-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--rs-border, #45475a);
}
.mi-triage-btn {
padding: 7px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
font-weight: 500;
}
.mi-triage-btn-cancel {
background: var(--rs-card-bg, #313244);
color: var(--rs-text, #cdd6f4);
}
.mi-triage-btn-cancel:hover { background: var(--rs-border, #45475a); }
.mi-triage-btn-commit {
background: #14b8a6;
color: white;
}
.mi-triage-btn-commit:hover { background: #0d9488; }
`;
document.head.appendChild(style);
}
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}