mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-16 19:51:11 +00:00
946 lines
33 KiB
TypeScript
946 lines
33 KiB
TypeScript
/* ===== Shared types (unchanged from the bus protocol) ===== */
|
|
|
|
export type Conversation = {
|
|
id: string;
|
|
kind: "direct" | "channel";
|
|
title?: string;
|
|
};
|
|
|
|
export type Attachment = {
|
|
id: string;
|
|
kind: "image" | "video" | "audio" | "file";
|
|
mimeType: string;
|
|
fileName?: string;
|
|
inline?: boolean;
|
|
url?: string;
|
|
contentBase64?: string;
|
|
width?: number;
|
|
height?: number;
|
|
durationMs?: number;
|
|
altText?: string;
|
|
transcript?: string;
|
|
};
|
|
|
|
export type Thread = {
|
|
id: string;
|
|
conversationId: string;
|
|
title: string;
|
|
};
|
|
|
|
export type Message = {
|
|
id: string;
|
|
direction: "inbound" | "outbound";
|
|
conversation: Conversation;
|
|
senderId: string;
|
|
senderName?: string;
|
|
text: string;
|
|
timestamp: number;
|
|
threadId?: string;
|
|
threadTitle?: string;
|
|
deleted?: boolean;
|
|
editedAt?: number;
|
|
attachments?: Attachment[];
|
|
reactions: Array<{ emoji: string; senderId: string }>;
|
|
};
|
|
|
|
export type BusEvent =
|
|
| { cursor: number; kind: "thread-created"; thread: Thread }
|
|
| { cursor: number; kind: string; message?: Message; emoji?: string };
|
|
|
|
export type Snapshot = {
|
|
conversations: Conversation[];
|
|
threads: Thread[];
|
|
messages: Message[];
|
|
events: BusEvent[];
|
|
};
|
|
|
|
export type ReportEnvelope = {
|
|
report: null | {
|
|
outputPath: string;
|
|
markdown: string;
|
|
generatedAt: string;
|
|
};
|
|
};
|
|
|
|
export type SeedScenario = {
|
|
id: string;
|
|
title: string;
|
|
surface: string;
|
|
objective: string;
|
|
successCriteria: string[];
|
|
docsRefs?: string[];
|
|
codeRefs?: string[];
|
|
};
|
|
|
|
export type Bootstrap = {
|
|
baseUrl: string;
|
|
latestReport: ReportEnvelope["report"];
|
|
controlUiUrl: string | null;
|
|
controlUiEmbeddedUrl: string | null;
|
|
kickoffTask: string;
|
|
scenarios: SeedScenario[];
|
|
defaults: {
|
|
conversationKind: "direct" | "channel";
|
|
conversationId: string;
|
|
senderId: string;
|
|
senderName: string;
|
|
};
|
|
runner: RunnerSnapshot;
|
|
runnerCatalog: {
|
|
status: "loading" | "ready" | "failed";
|
|
real: RunnerModelOption[];
|
|
};
|
|
};
|
|
|
|
export type ScenarioStep = {
|
|
name: string;
|
|
status: "pass" | "fail" | "skip";
|
|
details?: string;
|
|
};
|
|
|
|
export type ScenarioOutcome = {
|
|
id: string;
|
|
name: string;
|
|
status: "pending" | "running" | "pass" | "fail" | "skip";
|
|
details?: string;
|
|
steps?: ScenarioStep[];
|
|
startedAt?: string;
|
|
finishedAt?: string;
|
|
};
|
|
|
|
export type ScenarioRun = {
|
|
kind: "suite" | "self-check";
|
|
status: "idle" | "running" | "completed";
|
|
startedAt?: string;
|
|
finishedAt?: string;
|
|
scenarios: ScenarioOutcome[];
|
|
counts: {
|
|
total: number;
|
|
pending: number;
|
|
running: number;
|
|
passed: number;
|
|
failed: number;
|
|
skipped: number;
|
|
};
|
|
};
|
|
|
|
export type RunnerSelection = {
|
|
providerMode: "mock-openai" | "live-frontier";
|
|
primaryModel: string;
|
|
alternateModel: string;
|
|
fastMode: boolean;
|
|
scenarioIds: string[];
|
|
};
|
|
|
|
export type RunnerSnapshot = {
|
|
status: "idle" | "running" | "completed" | "failed";
|
|
selection: RunnerSelection;
|
|
startedAt?: string;
|
|
finishedAt?: string;
|
|
artifacts: null | {
|
|
outputDir: string;
|
|
reportPath: string;
|
|
summaryPath: string;
|
|
watchUrl: string;
|
|
};
|
|
error: string | null;
|
|
};
|
|
|
|
export type RunnerModelOption = {
|
|
key: string;
|
|
name: string;
|
|
provider: string;
|
|
input: string;
|
|
preferred: boolean;
|
|
};
|
|
|
|
export type OutcomesEnvelope = {
|
|
run: ScenarioRun | null;
|
|
};
|
|
|
|
export type TabId = "chat" | "results" | "report" | "events";
|
|
|
|
export type UiState = {
|
|
theme: "light" | "dark";
|
|
bootstrap: Bootstrap | null;
|
|
snapshot: Snapshot | null;
|
|
latestReport: ReportEnvelope["report"];
|
|
scenarioRun: ScenarioRun | null;
|
|
selectedConversationId: string | null;
|
|
selectedThreadId: string | null;
|
|
selectedScenarioId: string | null;
|
|
activeTab: TabId;
|
|
runnerDraft: RunnerSelection | null;
|
|
runnerDraftDirty: boolean;
|
|
composer: {
|
|
conversationKind: "direct" | "channel";
|
|
conversationId: string;
|
|
senderId: string;
|
|
senderName: string;
|
|
text: string;
|
|
};
|
|
busy: boolean;
|
|
error: string | null;
|
|
};
|
|
|
|
/* ===== Helpers ===== */
|
|
|
|
export function formatTime(timestamp: number) {
|
|
return new Date(timestamp).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
}
|
|
|
|
function formatIso(iso?: string) {
|
|
if (!iso) {
|
|
return "—";
|
|
}
|
|
return new Date(iso).toLocaleString([], {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
}
|
|
|
|
function esc(text: string) {
|
|
return text
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function attachmentSourceUrl(attachment: Attachment): string | null {
|
|
if (attachment.url?.trim()) {
|
|
return attachment.url;
|
|
}
|
|
if (attachment.contentBase64?.trim()) {
|
|
return `data:${attachment.mimeType};base64,${attachment.contentBase64}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function renderMessageAttachments(message: Message): string {
|
|
const attachments = message.attachments ?? [];
|
|
if (attachments.length === 0) {
|
|
return "";
|
|
}
|
|
const items = attachments
|
|
.map((attachment) => {
|
|
const sourceUrl = attachmentSourceUrl(attachment);
|
|
const label = attachment.fileName || attachment.altText || attachment.mimeType;
|
|
if (attachment.kind === "image" && sourceUrl) {
|
|
return `<figure class="msg-attachment msg-attachment-image">
|
|
<img src="${esc(sourceUrl)}" alt="${esc(attachment.altText || label)}" loading="lazy" />
|
|
<figcaption>${esc(label)}</figcaption>
|
|
</figure>`;
|
|
}
|
|
if (attachment.kind === "video" && sourceUrl) {
|
|
return `<figure class="msg-attachment msg-attachment-video">
|
|
<video controls preload="metadata" src="${esc(sourceUrl)}"></video>
|
|
<figcaption>${esc(label)}</figcaption>
|
|
</figure>`;
|
|
}
|
|
if (attachment.kind === "audio" && sourceUrl) {
|
|
return `<figure class="msg-attachment msg-attachment-audio">
|
|
<audio controls preload="metadata" src="${esc(sourceUrl)}"></audio>
|
|
<figcaption>${esc(label)}</figcaption>
|
|
</figure>`;
|
|
}
|
|
const transcript = attachment.transcript?.trim()
|
|
? `<div class="msg-attachment-transcript">${esc(attachment.transcript)}</div>`
|
|
: "";
|
|
const href = sourceUrl ? ` href="${esc(sourceUrl)}" target="_blank" rel="noreferrer"` : "";
|
|
return `<div class="msg-attachment msg-attachment-file">
|
|
<a class="msg-attachment-link"${href}>${esc(label)}</a>
|
|
${transcript}
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
return `<div class="msg-attachments">${items}</div>`;
|
|
}
|
|
|
|
const MOCK_MODELS: RunnerModelOption[] = [
|
|
{
|
|
key: "mock-openai/gpt-5.4",
|
|
name: "GPT-5.4 (mock)",
|
|
provider: "mock-openai",
|
|
input: "text",
|
|
preferred: true,
|
|
},
|
|
{
|
|
key: "mock-openai/gpt-5.4-alt",
|
|
name: "GPT-5.4 Alt (mock)",
|
|
provider: "mock-openai",
|
|
input: "text",
|
|
preferred: false,
|
|
},
|
|
];
|
|
|
|
export function deriveSelectedConversation(state: UiState): string | null {
|
|
return state.selectedConversationId ?? state.snapshot?.conversations[0]?.id ?? null;
|
|
}
|
|
|
|
export function deriveSelectedThread(state: UiState): string | null {
|
|
return state.selectedThreadId ?? null;
|
|
}
|
|
|
|
export function filteredMessages(state: UiState) {
|
|
const messages = state.snapshot?.messages ?? [];
|
|
return messages.filter((message) => {
|
|
if (state.selectedConversationId && message.conversation.id !== state.selectedConversationId) {
|
|
return false;
|
|
}
|
|
if (state.selectedThreadId && message.threadId !== state.selectedThreadId) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function findScenarioOutcome(state: UiState, scenario: SeedScenario) {
|
|
return (
|
|
state.scenarioRun?.scenarios.find((o) => o.id === scenario.id) ??
|
|
state.scenarioRun?.scenarios.find((o) => o.name === scenario.title) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function statusDotClass(status: ScenarioOutcome["status"] | "pending"): string {
|
|
return `scenario-item-dot scenario-item-dot-${status}`;
|
|
}
|
|
|
|
function badgeHtml(status: string): string {
|
|
const tone = status === "failed" ? "fail" : status === "completed" ? "pass" : status;
|
|
return `<span class="badge badge-${esc(tone)}">${esc(status)}</span>`;
|
|
}
|
|
|
|
function deriveSelection(state: UiState): RunnerSelection | null {
|
|
return state.runnerDraft ?? state.bootstrap?.runner.selection ?? null;
|
|
}
|
|
|
|
/* ===== Render: Header ===== */
|
|
|
|
function renderHeader(state: UiState): string {
|
|
const runner = state.bootstrap?.runner ?? null;
|
|
const run = state.scenarioRun;
|
|
const controlUrl = state.bootstrap?.controlUiUrl;
|
|
|
|
return `
|
|
<header class="header">
|
|
<div class="header-left">
|
|
<span class="header-title">QA Lab</span>
|
|
<div class="header-status">
|
|
${runner ? badgeHtml(runner.status) : ""}
|
|
${run ? `<span class="badge badge-accent">${run.counts.passed}/${run.counts.total} pass</span>` : ""}
|
|
${state.error ? `<span class="badge badge-fail">${esc(state.error)}</span>` : ""}
|
|
</div>
|
|
</div>
|
|
<div class="header-right">
|
|
${controlUrl ? `<a class="header-link" href="${esc(controlUrl)}" target="_blank" rel="noreferrer">Control UI</a>` : ""}
|
|
<button class="btn-ghost btn-sm" data-action="refresh"${state.busy ? " disabled" : ""}>Refresh</button>
|
|
<button class="btn-ghost btn-sm" data-action="reset"${state.busy ? " disabled" : ""}>Reset</button>
|
|
<button class="theme-toggle" data-action="toggle-theme" title="Toggle theme">${state.theme === "dark" ? "\u2600" : "\u263E"}</button>
|
|
</div>
|
|
</header>`;
|
|
}
|
|
|
|
/* ===== Render: Sidebar ===== */
|
|
|
|
function renderModelSelect(params: {
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
options: RunnerModelOption[];
|
|
disabled: boolean;
|
|
}): string {
|
|
const values = new Set(params.options.map((o) => o.key));
|
|
const options = [...params.options];
|
|
if (!values.has(params.value) && params.value.trim()) {
|
|
options.unshift({
|
|
key: params.value,
|
|
name: params.value,
|
|
provider: params.value.split("/")[0] ?? "custom",
|
|
input: "text",
|
|
preferred: false,
|
|
});
|
|
}
|
|
return `
|
|
<div class="config-field">
|
|
<span class="config-label">${esc(params.label)}</span>
|
|
<select id="${esc(params.id)}"${params.disabled ? " disabled" : ""}>
|
|
${options
|
|
.map(
|
|
(o) =>
|
|
`<option value="${esc(o.key)}"${o.key === params.value ? " selected" : ""}>${esc(o.key)}</option>`,
|
|
)
|
|
.join("")}
|
|
</select>
|
|
</div>`;
|
|
}
|
|
|
|
function renderSidebar(state: UiState): string {
|
|
const scenarios = state.bootstrap?.scenarios ?? [];
|
|
const selection = deriveSelection(state);
|
|
const runner = state.bootstrap?.runner ?? null;
|
|
const run = state.scenarioRun;
|
|
const isRunning = runner?.status === "running";
|
|
const realModels = state.bootstrap?.runnerCatalog.real ?? [];
|
|
const modelOptions =
|
|
selection?.providerMode === "live-frontier" && realModels.length > 0 ? realModels : MOCK_MODELS;
|
|
const selectedIds = new Set(selection?.scenarioIds ?? []);
|
|
|
|
return `
|
|
<aside class="sidebar">
|
|
<!-- Configuration -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-title"><h3>Configuration</h3></div>
|
|
<div class="config-field">
|
|
<span class="config-label">Provider lane</span>
|
|
<select id="provider-mode"${isRunning ? " disabled" : ""}>
|
|
<option value="mock-openai"${selection?.providerMode === "mock-openai" ? " selected" : ""}>Synthetic (mock)</option>
|
|
<option value="live-frontier"${selection?.providerMode === "live-frontier" ? " selected" : ""}>Real frontier providers</option>
|
|
</select>
|
|
</div>
|
|
${renderModelSelect({
|
|
id: "primary-model",
|
|
label: "Primary model",
|
|
value: selection?.primaryModel ?? "",
|
|
options: modelOptions,
|
|
disabled: isRunning,
|
|
})}
|
|
${renderModelSelect({
|
|
id: "alternate-model",
|
|
label: "Alternate model",
|
|
value: selection?.alternateModel ?? "",
|
|
options: modelOptions,
|
|
disabled: isRunning,
|
|
})}
|
|
${
|
|
selection?.providerMode === "live-frontier"
|
|
? `<div class="config-hint">${esc(
|
|
state.bootstrap?.runnerCatalog.status === "loading"
|
|
? "Loading model catalog\u2026"
|
|
: state.bootstrap?.runnerCatalog.status === "failed"
|
|
? "Catalog unavailable; using manual input."
|
|
: `${realModels.length} models available`,
|
|
)}</div>`
|
|
: ""
|
|
}
|
|
</div>
|
|
|
|
<!-- Scenarios -->
|
|
<div class="sidebar-section sidebar-scenarios">
|
|
<div class="sidebar-section-title">
|
|
<h3>Scenarios (${selectedIds.size}/${scenarios.length})</h3>
|
|
<div class="btn-group">
|
|
<button class="btn-sm btn-ghost" data-action="select-all-scenarios"${isRunning ? " disabled" : ""}>All</button>
|
|
<button class="btn-sm btn-ghost" data-action="clear-scenarios"${isRunning ? " disabled" : ""}>None</button>
|
|
</div>
|
|
</div>
|
|
<div class="scenario-scroll">
|
|
${scenarios
|
|
.map((s) => {
|
|
const outcome = findScenarioOutcome(state, s);
|
|
const status = outcome?.status ?? "pending";
|
|
return `
|
|
<label class="scenario-item">
|
|
<input type="checkbox" data-scenario-toggle-id="${esc(s.id)}"${selectedIds.has(s.id) ? " checked" : ""}${isRunning ? " disabled" : ""} />
|
|
<span class="${statusDotClass(status)}"></span>
|
|
<div class="scenario-item-info">
|
|
<span class="scenario-item-title">${esc(s.title)}</span>
|
|
<span class="scenario-item-meta">${esc(s.surface)} · ${esc(s.id)}</span>
|
|
</div>
|
|
</label>`;
|
|
})
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="sidebar-actions">
|
|
<button class="btn-primary" data-action="run-suite"${isRunning || !selectedIds.size || state.busy ? " disabled" : ""}>
|
|
Run ${selectedIds.size} scenario${selectedIds.size === 1 ? "" : "s"}
|
|
</button>
|
|
<div class="btn-row">
|
|
<button data-action="self-check"${isRunning || state.busy ? " disabled" : ""}>Self-check</button>
|
|
<button data-action="kickoff"${isRunning || state.busy ? " disabled" : ""}>Kickoff</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Run status -->
|
|
${run || runner ? renderRunStatus(state) : ""}
|
|
</aside>`;
|
|
}
|
|
|
|
function renderRunStatus(state: UiState): string {
|
|
const run = state.scenarioRun;
|
|
const runner = state.bootstrap?.runner ?? null;
|
|
if (!run && !runner) {
|
|
return "";
|
|
}
|
|
|
|
return `
|
|
<div class="sidebar-section run-status">
|
|
<div class="sidebar-section-title">
|
|
<h3>Run Status</h3>
|
|
${runner ? badgeHtml(runner.status) : ""}
|
|
</div>
|
|
${
|
|
run
|
|
? `<div class="run-counts">
|
|
<div class="run-count"><span class="run-count-value">${run.counts.total}</span><span class="run-count-label">Total</span></div>
|
|
<div class="run-count"><span class="run-count-value count-pass">${run.counts.passed}</span><span class="run-count-label">Pass</span></div>
|
|
<div class="run-count"><span class="run-count-value count-fail">${run.counts.failed}</span><span class="run-count-label">Fail</span></div>
|
|
<div class="run-count"><span class="run-count-value">${run.counts.pending + run.counts.running}</span><span class="run-count-label">Left</span></div>
|
|
</div>`
|
|
: ""
|
|
}
|
|
<div class="run-meta">
|
|
${runner?.startedAt ? `Started ${esc(formatIso(runner.startedAt))}` : ""}
|
|
${runner?.finishedAt ? `<br>Finished ${esc(formatIso(runner.finishedAt))}` : ""}
|
|
${runner?.error ? `<br><span style="color:var(--danger)">${esc(runner.error)}</span>` : ""}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/* ===== Render: Tab bar ===== */
|
|
|
|
function renderTabBar(state: UiState): string {
|
|
const tabs: Array<{ id: TabId; label: string }> = [
|
|
{ id: "chat", label: "Chat" },
|
|
{ id: "results", label: "Results" },
|
|
{ id: "report", label: "Report" },
|
|
{ id: "events", label: "Events" },
|
|
];
|
|
return `
|
|
<nav class="tab-bar">
|
|
${tabs
|
|
.map(
|
|
(t) =>
|
|
`<button class="tab-btn${state.activeTab === t.id ? " active" : ""}" data-tab="${t.id}">${t.label}</button>`,
|
|
)
|
|
.join("")}
|
|
<div class="tab-spacer"></div>
|
|
</nav>`;
|
|
}
|
|
|
|
/* ===== Render: Chat tab ===== */
|
|
|
|
function renderChatView(state: UiState): string {
|
|
const conversations = state.snapshot?.conversations ?? [];
|
|
const channels = conversations.filter((c) => c.kind === "channel");
|
|
const dms = conversations.filter((c) => c.kind === "direct");
|
|
const threads = (state.snapshot?.threads ?? []).filter(
|
|
(t) => !state.selectedConversationId || t.conversationId === state.selectedConversationId,
|
|
);
|
|
const selectedConv = deriveSelectedConversation(state);
|
|
const selectedThread = deriveSelectedThread(state);
|
|
const activeConversation = conversations.find((c) => c.id === selectedConv);
|
|
const messages = filteredMessages({
|
|
...state,
|
|
selectedConversationId: selectedConv,
|
|
selectedThreadId: selectedThread,
|
|
});
|
|
|
|
return `
|
|
<div class="chat-view">
|
|
<!-- Channel / DM sidebar -->
|
|
<aside class="chat-sidebar">
|
|
<div class="chat-sidebar-scroll">
|
|
<div class="chat-sidebar-section">
|
|
<div class="chat-sidebar-heading">Channels</div>
|
|
<div class="chat-sidebar-list">
|
|
${
|
|
channels.length === 0
|
|
? '<div class="chat-sidebar-item" style="color:var(--text-tertiary);font-size:12px;cursor:default">No channels</div>'
|
|
: channels
|
|
.map(
|
|
(c) => `
|
|
<button class="chat-sidebar-item${c.id === selectedConv ? " active" : ""}" data-conversation-id="${esc(c.id)}">
|
|
<span class="chat-sidebar-icon">#</span>
|
|
<span class="chat-sidebar-label">${esc(c.title || c.id)}</span>
|
|
</button>`,
|
|
)
|
|
.join("")
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="chat-sidebar-section">
|
|
<div class="chat-sidebar-heading">Direct Messages</div>
|
|
<div class="chat-sidebar-list">
|
|
${
|
|
dms.length === 0
|
|
? '<div class="chat-sidebar-item" style="color:var(--text-tertiary);font-size:12px;cursor:default">No DMs</div>'
|
|
: dms
|
|
.map(
|
|
(c) => `
|
|
<button class="chat-sidebar-item${c.id === selectedConv ? " active" : ""}" data-conversation-id="${esc(c.id)}">
|
|
<span class="chat-sidebar-icon">\u25CF</span>
|
|
<span class="chat-sidebar-label">${esc(c.title || c.id)}</span>
|
|
</button>`,
|
|
)
|
|
.join("")
|
|
}
|
|
</div>
|
|
</div>
|
|
${
|
|
threads.length > 0
|
|
? `<div class="chat-sidebar-section">
|
|
<div class="chat-sidebar-heading">Threads</div>
|
|
<div class="chat-sidebar-list">
|
|
<button class="chat-sidebar-item${!selectedThread ? " active" : ""}" data-thread-select="root">
|
|
<span class="chat-sidebar-icon">\u2302</span>
|
|
<span class="chat-sidebar-label">Main timeline</span>
|
|
</button>
|
|
${threads
|
|
.map(
|
|
(t) => `
|
|
<button class="chat-sidebar-item${t.id === selectedThread ? " active" : ""}" data-thread-select="${esc(t.id)}" data-thread-conv="${esc(t.conversationId)}">
|
|
<span class="chat-sidebar-icon">\u21B3</span>
|
|
<span class="chat-sidebar-label">${esc(t.title)}</span>
|
|
</button>`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>`
|
|
: ""
|
|
}
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main chat area -->
|
|
<div class="chat-main">
|
|
<!-- Channel header -->
|
|
<div class="chat-channel-header">
|
|
<span class="chat-channel-name">${esc(activeConversation?.title || selectedConv || "No conversation")}</span>
|
|
${activeConversation ? `<span class="chat-channel-kind">${activeConversation.kind}</span>` : ""}
|
|
${state.bootstrap?.runner.status === "running" ? '<span class="live-indicator"><span class="live-dot"></span>LIVE</span>' : ""}
|
|
</div>
|
|
|
|
<!-- Messages -->
|
|
<div class="chat-messages" id="chat-messages">
|
|
${
|
|
messages.length === 0
|
|
? '<div class="chat-empty">No messages yet. Run scenarios or send a message below.</div>'
|
|
: messages.map((m) => renderMessage(m)).join("")
|
|
}
|
|
</div>
|
|
|
|
<!-- Composer -->
|
|
<div class="chat-composer">
|
|
<div class="composer-context">
|
|
<select id="conversation-kind">
|
|
<option value="direct"${state.composer.conversationKind === "direct" ? " selected" : ""}>DM</option>
|
|
<option value="channel"${state.composer.conversationKind === "channel" ? " selected" : ""}>Channel</option>
|
|
</select>
|
|
<span>as</span>
|
|
<input id="sender-name" value="${esc(state.composer.senderName)}" placeholder="Name" />
|
|
<span>in</span>
|
|
<input id="conversation-id" value="${esc(state.composer.conversationId)}" placeholder="Conversation" />
|
|
<input id="sender-id" type="hidden" value="${esc(state.composer.senderId)}" />
|
|
</div>
|
|
<div class="composer-input">
|
|
<textarea id="composer-text" rows="1" placeholder="Type a message\u2026 (Enter to send, Shift+Enter for newline)">${esc(state.composer.text)}</textarea>
|
|
<button class="btn-primary composer-send" data-action="send"${state.busy ? " disabled" : ""}>Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function messageAvatar(m: Message): { emoji: string; bg: string; role: string } {
|
|
if (m.direction === "outbound") {
|
|
return { emoji: "\uD83E\uDD80", bg: "#7c6cff", role: "Claw" }; // 🦀
|
|
}
|
|
return { emoji: "\uD83E\uDD9E", bg: "#d97706", role: "Clawfather" }; // 🦞
|
|
}
|
|
|
|
function renderMessage(m: Message): string {
|
|
const name = m.senderName || m.senderId;
|
|
const avatar = messageAvatar(m);
|
|
const dirClass = m.direction === "inbound" ? "msg-direction-inbound" : "msg-direction-outbound";
|
|
|
|
const metaTags: string[] = [];
|
|
if (m.threadId) {
|
|
metaTags.push(`<span class="msg-tag">thread ${esc(m.threadId)}</span>`);
|
|
}
|
|
if (m.editedAt) {
|
|
metaTags.push('<span class="msg-tag">edited</span>');
|
|
}
|
|
if (m.deleted) {
|
|
metaTags.push('<span class="msg-tag">deleted</span>');
|
|
}
|
|
|
|
const reactions =
|
|
m.reactions.length > 0
|
|
? `<span class="msg-reactions">${m.reactions.map((r) => `<span class="msg-reaction">${esc(r.emoji)}</span>`).join("")}</span>`
|
|
: "";
|
|
|
|
return `
|
|
<div class="msg msg-${m.direction}">
|
|
<div class="msg-avatar" style="background:${avatar.bg}">${avatar.emoji}</div>
|
|
<div class="msg-body">
|
|
<div class="msg-header">
|
|
<span class="msg-sender">${esc(name)}</span>
|
|
<span class="msg-role">${esc(avatar.role)}</span>
|
|
<span class="msg-direction ${dirClass}">${m.direction === "inbound" ? "\u2B06" : "\u2B07"}</span>
|
|
<span class="msg-time">${formatTime(m.timestamp)}</span>
|
|
</div>
|
|
<div class="msg-text">${esc(m.text)}</div>
|
|
${renderMessageAttachments(m)}
|
|
${metaTags.length > 0 || reactions ? `<div class="msg-meta">${metaTags.join("")}${reactions}</div>` : ""}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function recentInspectorMessages(state: UiState, limit = 18) {
|
|
return (state.snapshot?.messages ?? []).slice(-limit).toReversed();
|
|
}
|
|
|
|
function renderInspectorLiveMessage(message: Message): string {
|
|
const avatar = messageAvatar(message);
|
|
const conversationLabel = message.conversation.title || message.conversation.id;
|
|
const threadLabel = message.threadTitle || message.threadId;
|
|
|
|
return `
|
|
<div class="inspector-live-message">
|
|
<div class="inspector-live-message-head">
|
|
<div class="inspector-live-message-identity">
|
|
<span class="inspector-live-avatar" style="background:${avatar.bg}">${avatar.emoji}</span>
|
|
<span class="inspector-live-sender">${esc(message.senderName || message.senderId)}</span>
|
|
<span class="inspector-live-direction inspector-live-direction-${message.direction}">${message.direction === "inbound" ? "inbound" : "outbound"}</span>
|
|
</div>
|
|
<span class="inspector-live-time">${formatTime(message.timestamp)}</span>
|
|
</div>
|
|
<div class="inspector-live-channel">
|
|
${esc(conversationLabel)}${threadLabel ? ` · ${esc(threadLabel)}` : ""}
|
|
</div>
|
|
<div class="inspector-live-text">${esc(message.text)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderInspectorLiveTranscript(state: UiState): string {
|
|
const messages = recentInspectorMessages(state);
|
|
const isLive = state.bootstrap?.runner.status === "running";
|
|
|
|
return `
|
|
<aside class="inspector-live">
|
|
<div class="inspector-live-header">
|
|
<div>
|
|
<div class="inspector-section-title">Live Transcript</div>
|
|
<div class="inspector-live-subtitle">
|
|
${isLive ? "Latest QA bus messages as the run progresses." : "Latest observed QA bus messages."}
|
|
</div>
|
|
</div>
|
|
${isLive ? '<span class="live-indicator"><span class="live-dot"></span>LIVE</span>' : ""}
|
|
</div>
|
|
<div class="inspector-live-feed">
|
|
${
|
|
messages.length > 0
|
|
? messages.map((message) => renderInspectorLiveMessage(message)).join("")
|
|
: '<div class="empty-state">No transcript yet. Start a run or send a message.</div>'
|
|
}
|
|
</div>
|
|
</aside>`;
|
|
}
|
|
|
|
/* ===== Render: Results tab ===== */
|
|
|
|
function renderResultsView(state: UiState): string {
|
|
const scenarios = state.bootstrap?.scenarios ?? [];
|
|
const selected = scenarios.find((s) => s.id === state.selectedScenarioId) ?? scenarios[0] ?? null;
|
|
|
|
return `
|
|
<div class="results-view">
|
|
<div class="results-list">
|
|
${scenarios.length === 0 ? '<div class="empty-state">No scenarios loaded.</div>' : ""}
|
|
${scenarios
|
|
.map((s) => {
|
|
const outcome = findScenarioOutcome(state, s);
|
|
const status = outcome?.status ?? "pending";
|
|
const isSelected = s.id === (selected?.id ?? null);
|
|
return `
|
|
<button class="result-card${isSelected ? " selected" : ""}" data-scenario-id="${esc(s.id)}">
|
|
<span class="result-card-dot scenario-item-dot-${status}"></span>
|
|
<div class="result-card-info">
|
|
<span class="result-card-title">${esc(s.title)}</span>
|
|
<span class="result-card-sub">${esc(s.surface)} · ${outcome?.steps?.length ?? s.successCriteria.length} steps</span>
|
|
</div>
|
|
${badgeHtml(status)}
|
|
</button>`;
|
|
})
|
|
.join("")}
|
|
</div>
|
|
<div class="results-inspector">
|
|
${selected ? renderInspector(state, selected) : '<div class="inspector-empty">Select a scenario</div>'}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderInspector(state: UiState, scenario: SeedScenario): string {
|
|
const outcome = findScenarioOutcome(state, scenario);
|
|
|
|
return `
|
|
<div class="inspector-layout">
|
|
<div class="inspector-main">
|
|
<div class="inspector-header">
|
|
<div>
|
|
<div class="inspector-title">${esc(scenario.title)}</div>
|
|
${badgeHtml(outcome?.status ?? "pending")}
|
|
</div>
|
|
</div>
|
|
<div class="inspector-objective">${esc(scenario.objective)}</div>
|
|
<div class="inspector-meta">
|
|
<div class="inspector-meta-item"><span class="inspector-meta-label">Surface</span><span class="inspector-meta-value">${esc(scenario.surface)}</span></div>
|
|
<div class="inspector-meta-item"><span class="inspector-meta-label">Started</span><span class="inspector-meta-value">${esc(formatIso(outcome?.startedAt))}</span></div>
|
|
<div class="inspector-meta-item"><span class="inspector-meta-label">Finished</span><span class="inspector-meta-value">${esc(formatIso(outcome?.finishedAt))}</span></div>
|
|
<div class="inspector-meta-item"><span class="inspector-meta-label">Run</span><span class="inspector-meta-value">${esc(state.scenarioRun?.kind ?? "seed only")}</span></div>
|
|
</div>
|
|
|
|
<div class="inspector-section">
|
|
<div class="inspector-section-title">Success Criteria</div>
|
|
<ul class="criteria-list">
|
|
${scenario.successCriteria.map((c) => `<li class="criteria-item"><span class="criteria-bullet"></span>${esc(c)}</li>`).join("")}
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="inspector-section">
|
|
<div class="inspector-section-title">Observed Outcome</div>
|
|
${
|
|
outcome
|
|
? `
|
|
${outcome.details ? `<div style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">${esc(outcome.details)}</div>` : ""}
|
|
<div class="step-list">
|
|
${
|
|
outcome.steps?.length
|
|
? outcome.steps
|
|
.map(
|
|
(step) => `
|
|
<div class="step-card">
|
|
<div class="step-card-header">
|
|
<span class="step-card-name">${esc(step.name)}</span>
|
|
${badgeHtml(step.status)}
|
|
</div>
|
|
${step.details ? `<div class="step-card-details">${esc(step.details)}</div>` : ""}
|
|
</div>`,
|
|
)
|
|
.join("")
|
|
: '<div class="empty-state">No step data yet.</div>'
|
|
}
|
|
</div>`
|
|
: '<div class="empty-state">Not executed yet — seed plan only.</div>'
|
|
}
|
|
</div>
|
|
|
|
${
|
|
scenario.docsRefs?.length
|
|
? `<div class="inspector-section">
|
|
<div class="inspector-section-title">Docs</div>
|
|
<div class="ref-list">${scenario.docsRefs.map((r) => `<span class="ref-tag">${esc(r)}</span>`).join("")}</div>
|
|
</div>`
|
|
: ""
|
|
}
|
|
${
|
|
scenario.codeRefs?.length
|
|
? `<div class="inspector-section">
|
|
<div class="inspector-section-title">Code</div>
|
|
<div class="ref-list">${scenario.codeRefs.map((r) => `<span class="ref-tag">${esc(r)}</span>`).join("")}</div>
|
|
</div>`
|
|
: ""
|
|
}
|
|
</div>
|
|
${renderInspectorLiveTranscript(state)}
|
|
</div>`;
|
|
}
|
|
|
|
/* ===== Render: Report tab ===== */
|
|
|
|
function renderReportView(state: UiState): string {
|
|
return `
|
|
<div class="report-view">
|
|
<div class="report-toolbar">
|
|
<span class="report-toolbar-title">Protocol Report</span>
|
|
<button class="btn-sm" data-action="download-report"${state.latestReport ? "" : " disabled"}>Export Markdown</button>
|
|
</div>
|
|
<div class="report-content">
|
|
<pre class="report-pre">${esc(state.latestReport?.markdown ?? "Run the suite or self-check to generate a report.")}</pre>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/* ===== Render: Events tab ===== */
|
|
|
|
function renderEventsView(state: UiState): string {
|
|
const events = (state.snapshot?.events ?? []).slice(-60).toReversed();
|
|
|
|
return `
|
|
<div class="events-view">
|
|
<div class="events-header">
|
|
<span class="events-header-title">Event Stream</span>
|
|
<span class="text-dimmed text-sm">${events.length} events (newest first)</span>
|
|
</div>
|
|
<div class="events-scroll">
|
|
${
|
|
events.length === 0
|
|
? '<div class="empty-state" style="padding:20px">No events yet.</div>'
|
|
: events
|
|
.map((e) => {
|
|
const detail =
|
|
"thread" in e
|
|
? `${e.thread.conversationId}/${e.thread.id}`
|
|
: e.message
|
|
? `${e.message.senderId}: ${e.message.text}`
|
|
: "";
|
|
return `
|
|
<div class="event-row">
|
|
<span class="event-kind">${esc(e.kind)}</span>
|
|
<span class="event-cursor">#${e.cursor}</span>
|
|
<span class="event-detail">${esc(detail)}</span>
|
|
</div>`;
|
|
})
|
|
.join("")
|
|
}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/* ===== Render: Active tab switch ===== */
|
|
|
|
function renderActiveTab(state: UiState): string {
|
|
switch (state.activeTab) {
|
|
case "chat":
|
|
return renderChatView(state);
|
|
case "results":
|
|
return renderResultsView(state);
|
|
case "report":
|
|
return renderReportView(state);
|
|
case "events":
|
|
return renderEventsView(state);
|
|
default:
|
|
return renderChatView(state);
|
|
}
|
|
}
|
|
|
|
/* ===== Main render ===== */
|
|
|
|
export function renderQaLabUi(state: UiState): string {
|
|
return `
|
|
<div class="app-shell" data-theme="${state.theme}">
|
|
${renderHeader(state)}
|
|
<div class="layout">
|
|
${renderSidebar(state)}
|
|
<main class="main-content">
|
|
${renderTabBar(state)}
|
|
<div class="tab-content">
|
|
${renderActiveTab(state)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>`;
|
|
}
|