mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 05:00:42 +00:00
1641 lines
59 KiB
TypeScript
1641 lines
59 KiB
TypeScript
import { defaultQaModelForMode, isQaFastModeEnabled } from "../../model-selection.js";
|
|
import { formatErrorMessage } from "./errors.js";
|
|
import {
|
|
type Bootstrap,
|
|
type OutcomesEnvelope,
|
|
type ReportEnvelope,
|
|
type RunnerSelection,
|
|
type Snapshot,
|
|
type TabId,
|
|
type CaptureEventsEnvelope,
|
|
type CaptureCoverageEnvelope,
|
|
type CaptureQueryEnvelope,
|
|
type CaptureSessionsEnvelope,
|
|
type CaptureStartupStatusEnvelope,
|
|
type CaptureSavedView,
|
|
type UiState,
|
|
renderQaLabUi,
|
|
} from "./ui-render.js";
|
|
|
|
async function getJson<T>(path: string): Promise<T> {
|
|
const response = await fetch(path);
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}`);
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
async function getJsonNoStore<T>(path: string): Promise<T> {
|
|
const response = await fetch(path, { cache: "no-store" });
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}`);
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
|
const response = await fetch(path, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!response.ok) {
|
|
const payload = (await response.json().catch(() => ({}))) as { error?: string };
|
|
throw new Error(payload.error || `${response.status} ${response.statusText}`);
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
function countCaptureDimension(
|
|
events: UiState["captureEvents"],
|
|
pick: (event: UiState["captureEvents"][number]) => string | undefined,
|
|
) {
|
|
const counts = new Map<string, number>();
|
|
for (const event of events) {
|
|
const value = pick(event)?.trim();
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
}
|
|
return [...counts.entries()]
|
|
.map(([value, count]) => ({ value, count }))
|
|
.toSorted((left, right) => right.count - left.count || left.value.localeCompare(right.value));
|
|
}
|
|
|
|
function summarizeCaptureCoverageFromEvents(
|
|
sessionIds: string[],
|
|
events: UiState["captureEvents"],
|
|
): UiState["captureCoverage"] {
|
|
const unlabeledEventCount = events.filter(
|
|
(event) => !event.provider?.trim() && !event.api?.trim() && !event.model?.trim(),
|
|
).length;
|
|
return {
|
|
sessionId: sessionIds.join(","),
|
|
totalEvents: events.length,
|
|
unlabeledEventCount,
|
|
providers: countCaptureDimension(events, (event) => event.provider),
|
|
apis: countCaptureDimension(events, (event) => event.api),
|
|
models: countCaptureDimension(events, (event) => event.model),
|
|
hosts: countCaptureDimension(events, (event) => event.host),
|
|
localPeers: countCaptureDimension(events, (event) => {
|
|
const host = event.host?.trim();
|
|
return host && /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(host) ? host : undefined;
|
|
}),
|
|
};
|
|
}
|
|
|
|
function defaultModelsForProviderMode(
|
|
mode: RunnerSelection["providerMode"],
|
|
bootstrap?: Bootstrap | null,
|
|
): Pick<RunnerSelection, "primaryModel" | "alternateModel" | "fastMode"> {
|
|
const preferredLiveModel = bootstrap?.runnerCatalog.real[0]?.key;
|
|
if (mode === "live-frontier") {
|
|
const primaryModel = defaultQaModelForMode(mode, { preferredLiveModel });
|
|
const alternateModel = defaultQaModelForMode(mode, { alternate: true, preferredLiveModel });
|
|
return {
|
|
primaryModel,
|
|
alternateModel,
|
|
fastMode: isQaFastModeEnabled({ primaryModel, alternateModel }),
|
|
};
|
|
}
|
|
const primaryModel = defaultQaModelForMode(mode);
|
|
const alternateModel = defaultQaModelForMode(mode, { alternate: true });
|
|
return {
|
|
primaryModel,
|
|
alternateModel,
|
|
fastMode: isQaFastModeEnabled({ primaryModel, alternateModel }),
|
|
};
|
|
}
|
|
|
|
function detectTheme(): "light" | "dark" {
|
|
const stored = localStorage.getItem("qa-lab-theme");
|
|
if (stored === "light" || stored === "dark") {
|
|
return stored;
|
|
}
|
|
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
|
}
|
|
|
|
function detectSidebarCollapsed(): boolean {
|
|
return localStorage.getItem("qa-lab-sidebar-collapsed") === "1";
|
|
}
|
|
|
|
function detectSidebarPanel(): UiState["sidebarPanel"] {
|
|
const stored = localStorage.getItem("qa-lab-sidebar-panel");
|
|
return stored === "config" || stored === "run" ? stored : "scenarios";
|
|
}
|
|
|
|
const CAPTURE_SAVED_VIEWS_KEY = "qa-lab-capture-saved-views";
|
|
|
|
function loadCaptureSavedViews(): CaptureSavedView[] {
|
|
try {
|
|
const raw = localStorage.getItem(CAPTURE_SAVED_VIEWS_KEY);
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
return Array.isArray(parsed) ? (parsed as CaptureSavedView[]) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function persistCaptureSavedViews(savedViews: CaptureSavedView[]) {
|
|
localStorage.setItem(CAPTURE_SAVED_VIEWS_KEY, JSON.stringify(savedViews));
|
|
}
|
|
|
|
function isEditableElement(target: EventTarget | null): boolean {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
return (
|
|
target.isContentEditable ||
|
|
target instanceof HTMLInputElement ||
|
|
target instanceof HTMLTextAreaElement ||
|
|
target instanceof HTMLSelectElement
|
|
);
|
|
}
|
|
|
|
export async function createQaLabApp(root: HTMLDivElement) {
|
|
const state: UiState = {
|
|
theme: detectTheme(),
|
|
bootstrap: null,
|
|
snapshot: null,
|
|
latestReport: null,
|
|
scenarioRun: null,
|
|
captureSessions: [],
|
|
captureEvents: [],
|
|
captureQueryPreset: "none",
|
|
captureQueryRows: [],
|
|
captureKindFilter: [],
|
|
captureProviderFilter: [],
|
|
captureHostFilter: [],
|
|
captureSearchText: "",
|
|
captureHeaderMode: "key",
|
|
captureViewMode: "list",
|
|
captureGroupMode: "none",
|
|
captureTimelineLaneMode: "domain",
|
|
captureTimelineLaneSort: "most-events",
|
|
captureTimelinePreviousLaneSort: null,
|
|
captureTimelineLaneSearch: "",
|
|
captureTimelineZoom: 100,
|
|
captureTimelineSparklineMode: "session-relative",
|
|
captureTimelineWindowStartPct: null,
|
|
captureTimelineWindowEndPct: null,
|
|
captureTimelineBrushAnchorPct: null,
|
|
captureTimelineBrushCurrentPct: null,
|
|
captureTimelineFocusSelectedFlow: false,
|
|
captureTimelineFocusedLaneMode: "all",
|
|
captureTimelineFocusedLaneThreshold: "any",
|
|
captureDetailPlacement: "right",
|
|
captureDetailSplitPct: 34,
|
|
captureDetailSplitDragging: false,
|
|
captureDetailView: "overview",
|
|
capturePreferredDetailView: null,
|
|
captureFlowDetailLayout: null,
|
|
capturePayloadDetailLayout: null,
|
|
capturePayloadExtent: "preview",
|
|
capturePayloadEventSort: "stream",
|
|
capturePayloadEventFilter: "",
|
|
captureErrorsOnly: false,
|
|
captureCoverage: null,
|
|
captureStartupStatus: null,
|
|
captureControlsExpanded: false,
|
|
captureSummaryExpanded: false,
|
|
captureSavedViews: loadCaptureSavedViews(),
|
|
captureSelectedSessionsExpanded: false,
|
|
sidebarCollapsed: detectSidebarCollapsed(),
|
|
sidebarPanel: detectSidebarPanel(),
|
|
captureCollapsedLaneIds: [],
|
|
capturePinnedLaneIds: [],
|
|
selectedCaptureSessionIds: [],
|
|
selectedCaptureEventKey: null,
|
|
selectedConversationId: null,
|
|
selectedThreadId: null,
|
|
selectedScenarioId: null,
|
|
activeTab: "chat",
|
|
runnerDraft: null,
|
|
runnerDraftDirty: false,
|
|
composer: {
|
|
conversationKind: "direct",
|
|
conversationId: "alice",
|
|
senderId: "alice",
|
|
senderName: "Alice",
|
|
text: "",
|
|
},
|
|
busy: false,
|
|
error: null,
|
|
};
|
|
|
|
/* Track whether user has scrolled up in the chat */
|
|
let chatScrollLocked = true;
|
|
let previousMessageCount = 0;
|
|
|
|
/* ---------- Render guards (avoid DOM churn during polling) ---------- */
|
|
|
|
let lastFingerprint = "";
|
|
let renderDeferred = false;
|
|
let previousRunnerStatus: string | null = null;
|
|
let currentUiVersion: string | null = null;
|
|
let syncingCaptureTimelineScroll = false;
|
|
let sparklineSweepActive = false;
|
|
let sparklineSweepAnchorStartPct: number | null = null;
|
|
let sparklineSweepAnchorEndPct: number | null = null;
|
|
let sparklineSweepCurrentStartPct: number | null = null;
|
|
let sparklineSweepCurrentEndPct: number | null = null;
|
|
let captureGlobalListenersBound = false;
|
|
|
|
function stateFingerprint(): string {
|
|
const msgs = state.snapshot?.messages;
|
|
const ev = state.snapshot?.events;
|
|
return JSON.stringify({
|
|
mc: msgs?.length ?? 0,
|
|
lm: msgs && msgs.length > 0 ? msgs[msgs.length - 1].id : null,
|
|
cc: state.snapshot?.conversations.length ?? 0,
|
|
tc: state.snapshot?.threads.length ?? 0,
|
|
ec: ev?.length ?? 0,
|
|
lc: ev && ev.length > 0 ? ev[ev.length - 1].cursor : -1,
|
|
rs: state.bootstrap?.runner.status,
|
|
ra: state.bootstrap?.runner.startedAt,
|
|
rf: state.bootstrap?.runner.finishedAt,
|
|
re: state.bootstrap?.runner.error,
|
|
ss: state.scenarioRun?.status,
|
|
sc: state.scenarioRun?.counts,
|
|
so: state.scenarioRun?.scenarios.map((o) => o.status).join(","),
|
|
rp: state.latestReport?.generatedAt,
|
|
cs: state.bootstrap?.runnerCatalog.status,
|
|
cl: state.bootstrap?.runnerCatalog.real.length ?? 0,
|
|
cps: state.captureSessions.length,
|
|
cse: state.captureSessions[0]?.eventCount ?? 0,
|
|
cei: state.selectedCaptureSessionIds.join(","),
|
|
cec: state.captureEvents.length,
|
|
ceh: state.captureEvents[0]?.host ?? null,
|
|
ccp: state.captureQueryPreset,
|
|
ccq: state.captureQueryRows.length,
|
|
ccv: state.captureCoverage?.totalEvents ?? 0,
|
|
ccpv: state.captureCoverage?.providers[0]?.value ?? null,
|
|
ccss: state.captureStartupStatus?.proxy.ok ?? null,
|
|
ccsg: state.captureStartupStatus?.gateway.ok ?? null,
|
|
ccce: state.captureControlsExpanded,
|
|
ccse: state.captureSummaryExpanded,
|
|
ccsx: state.captureSelectedSessionsExpanded,
|
|
ccsv: state.captureSavedViews.map((view) => `${view.id}:${view.name}`).join("|"),
|
|
scc: state.sidebarCollapsed,
|
|
scp: state.sidebarPanel,
|
|
cck: state.captureKindFilter.join(","),
|
|
ccpf: state.captureProviderFilter.join(","),
|
|
cchf: state.captureHostFilter.join(","),
|
|
cchm: state.captureHeaderMode,
|
|
ccgm: state.captureGroupMode,
|
|
cctl: state.captureTimelineLaneMode,
|
|
ccts: state.captureTimelineLaneSort,
|
|
cctps: state.captureTimelinePreviousLaneSort,
|
|
cctq: state.captureTimelineLaneSearch,
|
|
cctz: state.captureTimelineZoom,
|
|
cctsm: state.captureTimelineSparklineMode,
|
|
cctws: state.captureTimelineWindowStartPct,
|
|
cctwe: state.captureTimelineWindowEndPct,
|
|
cctba: state.captureTimelineBrushAnchorPct,
|
|
cctbc: state.captureTimelineBrushCurrentPct,
|
|
cctff: state.captureTimelineFocusSelectedFlow,
|
|
cctfm: state.captureTimelineFocusedLaneMode,
|
|
cctft: state.captureTimelineFocusedLaneThreshold,
|
|
ccdp: state.captureDetailPlacement,
|
|
ccds: state.captureDetailSplitPct,
|
|
ccdsd: state.captureDetailSplitDragging,
|
|
ccdv: state.captureDetailView,
|
|
ccpdv: state.capturePreferredDetailView,
|
|
ccdfl: state.captureFlowDetailLayout,
|
|
ccdpl: state.capturePayloadDetailLayout,
|
|
ccdpe: state.capturePayloadExtent,
|
|
ccpes: state.capturePayloadEventSort,
|
|
ccpef: state.capturePayloadEventFilter,
|
|
ccli: state.captureCollapsedLaneIds.join(","),
|
|
ccpi: state.capturePinnedLaneIds.join(","),
|
|
er: state.error,
|
|
});
|
|
}
|
|
|
|
function isSelectOpen(): boolean {
|
|
const active = document.activeElement;
|
|
return !!active && root.contains(active) && active.tagName === "SELECT";
|
|
}
|
|
|
|
/* ---------- Data fetching ---------- */
|
|
|
|
async function refresh() {
|
|
try {
|
|
const [bootstrap, snapshot, report, outcomes] = await Promise.all([
|
|
getJson<Bootstrap>("/api/bootstrap"),
|
|
getJson<Snapshot>("/api/state"),
|
|
getJson<ReportEnvelope>("/api/report"),
|
|
getJson<OutcomesEnvelope>("/api/outcomes"),
|
|
]);
|
|
state.bootstrap = bootstrap;
|
|
state.snapshot = snapshot;
|
|
state.latestReport = report.report ?? bootstrap.latestReport;
|
|
state.scenarioRun = outcomes.run;
|
|
if (!state.runnerDraft || !state.runnerDraftDirty) {
|
|
state.runnerDraft = {
|
|
...bootstrap.runner.selection,
|
|
scenarioIds: [...bootstrap.runner.selection.scenarioIds],
|
|
};
|
|
state.runnerDraftDirty = false;
|
|
}
|
|
if (!state.selectedConversationId) {
|
|
state.selectedConversationId = snapshot.conversations[0]?.id ?? null;
|
|
}
|
|
if (!state.selectedScenarioId) {
|
|
state.selectedScenarioId = bootstrap.scenarios[0]?.id ?? null;
|
|
}
|
|
if (!state.composer.conversationId) {
|
|
state.composer = {
|
|
...state.composer,
|
|
conversationKind: bootstrap.defaults.conversationKind,
|
|
conversationId: bootstrap.defaults.conversationId,
|
|
senderId: bootstrap.defaults.senderId,
|
|
senderName: bootstrap.defaults.senderName,
|
|
};
|
|
}
|
|
state.error = null;
|
|
} catch (error) {
|
|
state.error = formatErrorMessage(error);
|
|
}
|
|
|
|
try {
|
|
const sessions = await getJson<CaptureSessionsEnvelope>("/api/capture/sessions");
|
|
const startupStatusPromise = getJson<CaptureStartupStatusEnvelope>(
|
|
"/api/capture/startup-status",
|
|
);
|
|
state.captureSessions = sessions.sessions;
|
|
const availableSessionIds = new Set(sessions.sessions.map((session) => session.id));
|
|
state.selectedCaptureSessionIds = state.selectedCaptureSessionIds.filter((id) =>
|
|
availableSessionIds.has(id),
|
|
);
|
|
if (state.selectedCaptureSessionIds.length === 0) {
|
|
state.selectedCaptureSessionIds = sessions.sessions[0]?.id ? [sessions.sessions[0].id] : [];
|
|
}
|
|
const startupStatusResult = await Promise.allSettled([startupStatusPromise]);
|
|
state.captureStartupStatus =
|
|
startupStatusResult[0]?.status === "fulfilled" ? startupStatusResult[0].value.status : null;
|
|
if (state.selectedCaptureSessionIds.length > 0) {
|
|
const eventsPromises = state.selectedCaptureSessionIds.map((sessionId) =>
|
|
getJson<CaptureEventsEnvelope>(
|
|
`/api/capture/events?sessionId=${encodeURIComponent(sessionId)}`,
|
|
),
|
|
);
|
|
const singleSessionId =
|
|
state.selectedCaptureSessionIds.length === 1 ? state.selectedCaptureSessionIds[0] : null;
|
|
const coveragePromise = singleSessionId
|
|
? getJson<CaptureCoverageEnvelope>(
|
|
`/api/capture/coverage?sessionId=${encodeURIComponent(singleSessionId)}`,
|
|
)
|
|
: Promise.resolve<CaptureCoverageEnvelope | null>(null);
|
|
const queryPromise =
|
|
state.captureQueryPreset === "none"
|
|
? Promise.resolve<CaptureQueryEnvelope>({ rows: [] })
|
|
: singleSessionId
|
|
? getJson<CaptureQueryEnvelope>(
|
|
`/api/capture/query?sessionId=${encodeURIComponent(
|
|
singleSessionId,
|
|
)}&preset=${encodeURIComponent(state.captureQueryPreset)}`,
|
|
)
|
|
: Promise.resolve<CaptureQueryEnvelope>({ rows: [] });
|
|
const [eventsResult, coverageResult, queryResult] = await Promise.allSettled([
|
|
Promise.all(eventsPromises),
|
|
coveragePromise,
|
|
queryPromise,
|
|
]);
|
|
if (eventsResult.status !== "fulfilled") {
|
|
throw eventsResult.reason;
|
|
}
|
|
state.captureEvents = eventsResult.value
|
|
.flatMap((envelope) => envelope.events)
|
|
.toSorted(
|
|
(left, right) =>
|
|
right.ts - left.ts || String(right.id ?? "").localeCompare(String(left.id ?? "")),
|
|
);
|
|
state.captureCoverage =
|
|
coverageResult.status === "fulfilled" && coverageResult.value
|
|
? coverageResult.value.coverage
|
|
: summarizeCaptureCoverageFromEvents(
|
|
state.selectedCaptureSessionIds,
|
|
state.captureEvents,
|
|
);
|
|
state.captureQueryRows = queryResult.status === "fulfilled" ? queryResult.value.rows : [];
|
|
if (
|
|
!state.selectedCaptureEventKey ||
|
|
!state.captureEvents.some(
|
|
(event) =>
|
|
`${event.id ?? "no-id"}:${event.flowId}:${event.ts}:${event.kind}` ===
|
|
state.selectedCaptureEventKey,
|
|
)
|
|
) {
|
|
const first = state.captureEvents[0];
|
|
state.selectedCaptureEventKey = first
|
|
? `${first.id ?? "no-id"}:${first.flowId}:${first.ts}:${first.kind}`
|
|
: null;
|
|
}
|
|
} else {
|
|
state.captureEvents = [];
|
|
state.captureCoverage = null;
|
|
state.captureQueryRows = [];
|
|
state.selectedCaptureEventKey = null;
|
|
}
|
|
} catch (error) {
|
|
state.error = formatErrorMessage(error);
|
|
}
|
|
|
|
/* Auto-switch to chat when a run starts so user can watch live */
|
|
const currentRunnerStatus = state.bootstrap?.runner.status ?? null;
|
|
if (currentRunnerStatus === "running" && previousRunnerStatus !== "running") {
|
|
state.activeTab = "chat";
|
|
chatScrollLocked = true;
|
|
}
|
|
previousRunnerStatus = currentRunnerStatus;
|
|
|
|
/* Only re-render when data actually changed; defer if a <select> is open */
|
|
const fp = stateFingerprint();
|
|
if (fp !== lastFingerprint) {
|
|
lastFingerprint = fp;
|
|
renderDeferred = true;
|
|
}
|
|
if (renderDeferred && !isSelectOpen()) {
|
|
renderDeferred = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
async function pollUiVersion() {
|
|
if (document.visibilityState === "hidden") {
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await getJsonNoStore<{ version: string | null }>("/api/ui-version");
|
|
if (!currentUiVersion) {
|
|
currentUiVersion = payload.version;
|
|
return;
|
|
}
|
|
if (payload.version && payload.version !== currentUiVersion) {
|
|
window.location.reload();
|
|
}
|
|
} catch {
|
|
// Ignore transient rebuild windows while the dist dir is being rewritten.
|
|
}
|
|
}
|
|
|
|
/* ---------- Draft mutations ---------- */
|
|
|
|
function updateRunnerDraft(mutator: (draft: RunnerSelection) => RunnerSelection) {
|
|
const fallback = state.bootstrap?.runner.selection;
|
|
if (!state.runnerDraft && fallback) {
|
|
state.runnerDraft = { ...fallback, scenarioIds: [...fallback.scenarioIds] };
|
|
}
|
|
if (!state.runnerDraft) {
|
|
return;
|
|
}
|
|
state.runnerDraft = mutator(state.runnerDraft);
|
|
state.runnerDraftDirty = true;
|
|
render();
|
|
}
|
|
|
|
/* ---------- Actions ---------- */
|
|
|
|
async function runSelfCheck() {
|
|
state.busy = true;
|
|
state.error = null;
|
|
render();
|
|
try {
|
|
const result = await postJson<{ report: string; outputPath: string }>(
|
|
"/api/scenario/self-check",
|
|
{},
|
|
);
|
|
state.latestReport = {
|
|
outputPath: result.outputPath,
|
|
markdown: result.report,
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
state.activeTab = "report";
|
|
await refresh();
|
|
} catch (error) {
|
|
state.error = formatErrorMessage(error);
|
|
render();
|
|
} finally {
|
|
state.busy = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
async function resetState() {
|
|
state.busy = true;
|
|
render();
|
|
try {
|
|
await postJson("/api/reset", {});
|
|
state.latestReport = null;
|
|
state.selectedThreadId = null;
|
|
await refresh();
|
|
} catch (error) {
|
|
state.error = formatErrorMessage(error);
|
|
render();
|
|
} finally {
|
|
state.busy = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
async function sendInbound() {
|
|
const conversationId = state.composer.conversationId.trim();
|
|
const text = state.composer.text.trim();
|
|
if (!conversationId || !text) {
|
|
state.error = "Conversation id and text are required.";
|
|
render();
|
|
return;
|
|
}
|
|
state.busy = true;
|
|
state.error = null;
|
|
render();
|
|
try {
|
|
await postJson("/api/inbound/message", {
|
|
conversation: {
|
|
id: conversationId,
|
|
kind: state.composer.conversationKind,
|
|
...(state.composer.conversationKind === "channel" ? { title: conversationId } : {}),
|
|
},
|
|
senderId: state.composer.senderId.trim() || "alice",
|
|
senderName: state.composer.senderName.trim() || undefined,
|
|
text,
|
|
...(state.selectedThreadId ? { threadId: state.selectedThreadId } : {}),
|
|
});
|
|
state.selectedConversationId = conversationId;
|
|
state.composer.text = "";
|
|
chatScrollLocked = true;
|
|
await refresh();
|
|
} catch (error) {
|
|
state.error = formatErrorMessage(error);
|
|
render();
|
|
} finally {
|
|
state.busy = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
async function runSuite() {
|
|
if (!state.runnerDraft) {
|
|
state.error = "Runner selection not ready yet.";
|
|
render();
|
|
return;
|
|
}
|
|
state.busy = true;
|
|
state.error = null;
|
|
render();
|
|
try {
|
|
const result = await postJson<{ runner: { selection: RunnerSelection } }>(
|
|
"/api/scenario/suite",
|
|
{
|
|
providerMode: state.runnerDraft.providerMode,
|
|
primaryModel: state.runnerDraft.primaryModel,
|
|
alternateModel: state.runnerDraft.alternateModel,
|
|
scenarioIds: state.runnerDraft.scenarioIds,
|
|
},
|
|
);
|
|
state.runnerDraft = {
|
|
...result.runner.selection,
|
|
scenarioIds: [...result.runner.selection.scenarioIds],
|
|
};
|
|
state.runnerDraftDirty = false;
|
|
state.activeTab = "chat";
|
|
await refresh();
|
|
} catch (error) {
|
|
state.error = formatErrorMessage(error);
|
|
render();
|
|
} finally {
|
|
state.busy = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
async function sendKickoff() {
|
|
state.busy = true;
|
|
state.error = null;
|
|
render();
|
|
try {
|
|
await postJson("/api/kickoff", {});
|
|
state.activeTab = "chat";
|
|
chatScrollLocked = true;
|
|
await refresh();
|
|
} catch (error) {
|
|
state.error = formatErrorMessage(error);
|
|
render();
|
|
} finally {
|
|
state.busy = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
function downloadReport() {
|
|
if (!state.latestReport?.markdown) {
|
|
return;
|
|
}
|
|
const blob = new Blob([state.latestReport.markdown], { type: "text/markdown;charset=utf-8" });
|
|
const href = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = href;
|
|
anchor.download = "qa-report.md";
|
|
anchor.click();
|
|
URL.revokeObjectURL(href);
|
|
}
|
|
|
|
function toggleTheme() {
|
|
state.theme = state.theme === "dark" ? "light" : "dark";
|
|
localStorage.setItem("qa-lab-theme", state.theme);
|
|
render();
|
|
}
|
|
|
|
function toggleSidebar() {
|
|
state.sidebarCollapsed = !state.sidebarCollapsed;
|
|
localStorage.setItem("qa-lab-sidebar-collapsed", state.sidebarCollapsed ? "1" : "0");
|
|
render();
|
|
}
|
|
|
|
function setSidebarPanel(panel: UiState["sidebarPanel"]) {
|
|
state.sidebarPanel = panel;
|
|
localStorage.setItem("qa-lab-sidebar-panel", panel);
|
|
if (state.sidebarCollapsed) {
|
|
state.sidebarCollapsed = false;
|
|
localStorage.setItem("qa-lab-sidebar-collapsed", "0");
|
|
}
|
|
render();
|
|
}
|
|
|
|
function applyCaptureSavedView(view: CaptureSavedView) {
|
|
state.selectedCaptureSessionIds = [...view.sessionIds];
|
|
state.captureKindFilter = [...view.kindFilter];
|
|
state.captureProviderFilter = [...view.providerFilter];
|
|
state.captureHostFilter = [...view.hostFilter];
|
|
state.captureSearchText = view.searchText;
|
|
state.captureHeaderMode = view.headerMode;
|
|
state.captureViewMode = view.viewMode;
|
|
state.captureGroupMode = view.groupMode;
|
|
state.captureTimelineLaneMode = view.timelineLaneMode;
|
|
state.captureTimelineLaneSort = view.timelineLaneSort;
|
|
state.captureTimelineZoom = view.timelineZoom;
|
|
state.captureTimelineSparklineMode = view.timelineSparklineMode;
|
|
state.captureErrorsOnly = view.errorsOnly;
|
|
state.captureDetailPlacement = view.detailPlacement;
|
|
state.capturePayloadDetailLayout = view.payloadLayout;
|
|
state.capturePayloadExtent = view.payloadExtent;
|
|
state.selectedCaptureEventKey = null;
|
|
}
|
|
|
|
function buildCaptureSavedView(name: string): CaptureSavedView {
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
name,
|
|
sessionIds: [...state.selectedCaptureSessionIds],
|
|
kindFilter: [...state.captureKindFilter],
|
|
providerFilter: [...state.captureProviderFilter],
|
|
hostFilter: [...state.captureHostFilter],
|
|
searchText: state.captureSearchText,
|
|
headerMode: state.captureHeaderMode,
|
|
viewMode: state.captureViewMode,
|
|
groupMode: state.captureGroupMode,
|
|
timelineLaneMode: state.captureTimelineLaneMode,
|
|
timelineLaneSort: state.captureTimelineLaneSort,
|
|
timelineZoom: state.captureTimelineZoom,
|
|
timelineSparklineMode: state.captureTimelineSparklineMode,
|
|
errorsOnly: state.captureErrorsOnly,
|
|
detailPlacement: state.captureDetailPlacement,
|
|
payloadLayout: state.capturePayloadDetailLayout,
|
|
payloadExtent: state.capturePayloadExtent,
|
|
};
|
|
}
|
|
|
|
/* ---------- Chat scroll tracking ---------- */
|
|
|
|
function trackChatScroll() {
|
|
const el = root.querySelector<HTMLElement>("#chat-messages");
|
|
if (!el) {
|
|
return;
|
|
}
|
|
el.addEventListener("scroll", () => {
|
|
const threshold = 40;
|
|
chatScrollLocked = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
|
});
|
|
}
|
|
|
|
function scrollChatToBottom(force?: boolean) {
|
|
const el = root.querySelector<HTMLElement>("#chat-messages");
|
|
if (!el) {
|
|
return;
|
|
}
|
|
const newCount = state.snapshot?.messages.length ?? 0;
|
|
if (force || (chatScrollLocked && newCount !== previousMessageCount)) {
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
previousMessageCount = newCount;
|
|
}
|
|
|
|
/* ---------- Event binding ---------- */
|
|
|
|
function bindEvents() {
|
|
/* Tabs */
|
|
root.querySelectorAll<HTMLElement>("[data-tab]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
const nextTab = node.dataset.tab as TabId | undefined;
|
|
if (nextTab) {
|
|
state.activeTab = nextTab;
|
|
render();
|
|
}
|
|
});
|
|
});
|
|
|
|
/* Conversation chips */
|
|
root.querySelectorAll<HTMLElement>("[data-conversation-id]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
state.selectedConversationId = node.dataset.conversationId ?? null;
|
|
state.selectedThreadId = null;
|
|
if (state.activeTab !== "chat") {
|
|
state.activeTab = "chat";
|
|
}
|
|
render();
|
|
});
|
|
});
|
|
|
|
/* Thread chips */
|
|
root.querySelectorAll<HTMLElement>("[data-thread-select]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
const val = node.dataset.threadSelect;
|
|
if (val === "root") {
|
|
state.selectedThreadId = null;
|
|
} else {
|
|
state.selectedThreadId = val ?? null;
|
|
const conv = node.dataset.threadConv;
|
|
if (conv) {
|
|
state.selectedConversationId = conv;
|
|
}
|
|
}
|
|
render();
|
|
});
|
|
});
|
|
|
|
/* Scenario selection (results tab + sidebar) */
|
|
root.querySelectorAll<HTMLElement>("[data-scenario-id]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
state.selectedScenarioId = node.dataset.scenarioId ?? null;
|
|
if (state.activeTab !== "results") {
|
|
state.activeTab = "results";
|
|
}
|
|
render();
|
|
});
|
|
});
|
|
|
|
/* Header / sidebar buttons */
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='refresh']")
|
|
?.addEventListener("click", () => void refresh());
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='reset']")
|
|
?.addEventListener("click", () => void resetState());
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='toggle-theme']")
|
|
?.addEventListener("click", toggleTheme);
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='toggle-sidebar']")
|
|
?.addEventListener("click", toggleSidebar);
|
|
root.querySelectorAll<HTMLElement>("[data-sidebar-panel]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
const panel = node.dataset.sidebarPanel;
|
|
if (panel === "config" || panel === "run" || panel === "scenarios") {
|
|
setSidebarPanel(panel);
|
|
}
|
|
});
|
|
});
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='self-check']")
|
|
?.addEventListener("click", () => void runSelfCheck());
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='run-suite']")
|
|
?.addEventListener("click", () => void runSuite());
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='kickoff']")
|
|
?.addEventListener("click", () => void sendKickoff());
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='send']")
|
|
?.addEventListener("click", () => void sendInbound());
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='download-report']")
|
|
?.addEventListener("click", downloadReport);
|
|
|
|
/* Scenario All/None */
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='select-all-scenarios']")
|
|
?.addEventListener("click", () => {
|
|
updateRunnerDraft((d) => ({
|
|
...d,
|
|
scenarioIds: state.bootstrap?.scenarios.map((s) => s.id) ?? d.scenarioIds,
|
|
}));
|
|
});
|
|
root
|
|
.querySelector<HTMLElement>("[data-action='clear-scenarios']")
|
|
?.addEventListener("click", () => {
|
|
updateRunnerDraft((d) => ({ ...d, scenarioIds: [] }));
|
|
});
|
|
|
|
/* Scenario toggles */
|
|
root.querySelectorAll<HTMLInputElement>("[data-scenario-toggle-id]").forEach((node) => {
|
|
node.addEventListener("change", () => {
|
|
const scenarioId = node.dataset.scenarioToggleId;
|
|
if (!scenarioId) {
|
|
return;
|
|
}
|
|
updateRunnerDraft((draft) => {
|
|
const selected = new Set(draft.scenarioIds);
|
|
if (node.checked) {
|
|
selected.add(scenarioId);
|
|
} else {
|
|
selected.delete(scenarioId);
|
|
}
|
|
const orderedIds = state.bootstrap?.scenarios
|
|
.map((s) => s.id)
|
|
.filter((id) => selected.has(id)) ?? [...selected];
|
|
return { ...draft, scenarioIds: orderedIds };
|
|
});
|
|
});
|
|
});
|
|
|
|
/* Config form */
|
|
root.querySelector<HTMLSelectElement>("#provider-mode")?.addEventListener("change", (e) => {
|
|
const mode =
|
|
(e.currentTarget as HTMLSelectElement).value === "live-frontier"
|
|
? "live-frontier"
|
|
: "mock-openai";
|
|
updateRunnerDraft((d) => ({
|
|
...d,
|
|
providerMode: mode,
|
|
...defaultModelsForProviderMode(mode, state.bootstrap),
|
|
}));
|
|
});
|
|
root.querySelector<HTMLSelectElement>("#primary-model")?.addEventListener("change", (e) => {
|
|
const primaryModel = (e.currentTarget as HTMLSelectElement).value;
|
|
updateRunnerDraft((d) => ({
|
|
...d,
|
|
primaryModel,
|
|
fastMode: isQaFastModeEnabled({ primaryModel, alternateModel: d.alternateModel }),
|
|
}));
|
|
});
|
|
root.querySelector<HTMLSelectElement>("#alternate-model")?.addEventListener("change", (e) => {
|
|
const alternateModel = (e.currentTarget as HTMLSelectElement).value;
|
|
updateRunnerDraft((d) => ({
|
|
...d,
|
|
alternateModel,
|
|
fastMode: isQaFastModeEnabled({ primaryModel: d.primaryModel, alternateModel }),
|
|
}));
|
|
});
|
|
|
|
root.querySelector<HTMLSelectElement>("#capture-session")?.addEventListener("change", (e) => {
|
|
state.selectedCaptureSessionIds = readMultiSelect(e.currentTarget as HTMLSelectElement);
|
|
state.selectedCaptureEventKey = null;
|
|
void refresh();
|
|
});
|
|
root.querySelector<HTMLButtonElement>("#capture-save-view")?.addEventListener("click", () => {
|
|
const name = window.prompt("Saved view name");
|
|
const trimmed = name?.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
state.captureSavedViews = [buildCaptureSavedView(trimmed), ...state.captureSavedViews].slice(
|
|
0,
|
|
12,
|
|
);
|
|
persistCaptureSavedViews(state.captureSavedViews);
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-saved-view")
|
|
?.addEventListener("change", (e) => {
|
|
const id = (e.currentTarget as HTMLSelectElement).value;
|
|
const view = state.captureSavedViews.find((candidate) => candidate.id === id);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
applyCaptureSavedView(view);
|
|
void refresh();
|
|
});
|
|
root.querySelector<HTMLButtonElement>("#capture-delete-view")?.addEventListener("click", () => {
|
|
const select = root.querySelector<HTMLSelectElement>("#capture-saved-view");
|
|
const id = select?.value?.trim();
|
|
if (!id) {
|
|
return;
|
|
}
|
|
state.captureSavedViews = state.captureSavedViews.filter((view) => view.id !== id);
|
|
persistCaptureSavedViews(state.captureSavedViews);
|
|
render();
|
|
});
|
|
root.querySelectorAll<HTMLButtonElement>("[data-capture-session-remove]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
const sessionId = node.dataset.captureSessionRemove?.trim();
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
state.selectedCaptureSessionIds = state.selectedCaptureSessionIds.filter(
|
|
(id) => id !== sessionId,
|
|
);
|
|
state.selectedCaptureEventKey = null;
|
|
void refresh();
|
|
});
|
|
});
|
|
root
|
|
.querySelector<HTMLButtonElement>("#capture-toggle-selected-sessions")
|
|
?.addEventListener("click", () => {
|
|
state.captureSelectedSessionsExpanded = !state.captureSelectedSessionsExpanded;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLButtonElement>("#capture-delete-selected-sessions")
|
|
?.addEventListener("click", async () => {
|
|
if (state.selectedCaptureSessionIds.length === 0) {
|
|
return;
|
|
}
|
|
const confirmed = window.confirm(
|
|
`Delete ${state.selectedCaptureSessionIds.length} selected capture session${
|
|
state.selectedCaptureSessionIds.length === 1 ? "" : "s"
|
|
}?`,
|
|
);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
await postJson("/api/capture/delete-sessions", {
|
|
sessionIds: state.selectedCaptureSessionIds,
|
|
});
|
|
state.selectedCaptureSessionIds = [];
|
|
state.selectedCaptureEventKey = null;
|
|
await refresh();
|
|
});
|
|
root
|
|
.querySelector<HTMLButtonElement>("#capture-purge-all")
|
|
?.addEventListener("click", async () => {
|
|
const confirmed = window.confirm("Purge all captured sessions, events, and blobs?");
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
await postJson("/api/capture/purge", {});
|
|
state.selectedCaptureSessionIds = [];
|
|
state.selectedCaptureEventKey = null;
|
|
await refresh();
|
|
});
|
|
root.querySelector<HTMLSelectElement>("#capture-preset")?.addEventListener("change", (e) => {
|
|
state.captureQueryPreset = (e.currentTarget as HTMLSelectElement)
|
|
.value as UiState["captureQueryPreset"];
|
|
void refresh();
|
|
});
|
|
const readMultiSelect = (select: HTMLSelectElement) =>
|
|
[...select.selectedOptions].map((option) => option.value).filter(Boolean);
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-kind-filter")
|
|
?.addEventListener("change", (e) => {
|
|
state.captureKindFilter = readMultiSelect(e.currentTarget as HTMLSelectElement);
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-provider-filter")
|
|
?.addEventListener("change", (e) => {
|
|
state.captureProviderFilter = readMultiSelect(e.currentTarget as HTMLSelectElement);
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-host-filter")
|
|
?.addEventListener("change", (e) => {
|
|
state.captureHostFilter = readMultiSelect(e.currentTarget as HTMLSelectElement);
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-header-mode")
|
|
?.addEventListener("change", (e) => {
|
|
const value = (e.currentTarget as HTMLSelectElement).value;
|
|
state.captureHeaderMode = value === "all" || value === "hidden" ? value : "key";
|
|
render();
|
|
});
|
|
root.querySelector<HTMLSelectElement>("#capture-view-mode")?.addEventListener("change", (e) => {
|
|
state.captureViewMode =
|
|
(e.currentTarget as HTMLSelectElement).value === "timeline" ? "timeline" : "list";
|
|
state.captureCollapsedLaneIds = [];
|
|
state.capturePinnedLaneIds = [];
|
|
state.captureTimelineWindowStartPct = null;
|
|
state.captureTimelineWindowEndPct = null;
|
|
state.captureTimelineBrushAnchorPct = null;
|
|
state.captureTimelineBrushCurrentPct = null;
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-group-mode")
|
|
?.addEventListener("change", (e) => {
|
|
const value = (e.currentTarget as HTMLSelectElement).value;
|
|
state.captureGroupMode =
|
|
value === "flow" || value === "host-path" || value === "burst" ? value : "none";
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-timeline-lane-mode")
|
|
?.addEventListener("change", (e) => {
|
|
const value = (e.currentTarget as HTMLSelectElement).value;
|
|
state.captureTimelineLaneMode = value === "provider" || value === "flow" ? value : "domain";
|
|
state.captureTimelinePreviousLaneSort = null;
|
|
state.captureCollapsedLaneIds = [];
|
|
state.capturePinnedLaneIds = [];
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-timeline-lane-sort")
|
|
?.addEventListener("change", (e) => {
|
|
const value = (e.currentTarget as HTMLSelectElement).value;
|
|
const nextSort =
|
|
value === "most-errors" || value === "severity" || value === "alphabetical"
|
|
? value
|
|
: "most-events";
|
|
if (nextSort !== state.captureTimelineLaneSort) {
|
|
state.captureTimelinePreviousLaneSort = state.captureTimelineLaneSort;
|
|
}
|
|
state.captureTimelineLaneSort = nextSort;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLInputElement>("#capture-timeline-lane-search")
|
|
?.addEventListener("input", (e) => {
|
|
state.captureTimelineLaneSearch = (e.currentTarget as HTMLInputElement).value ?? "";
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-timeline-zoom")
|
|
?.addEventListener("change", (e) => {
|
|
const value = Number((e.currentTarget as HTMLSelectElement).value);
|
|
state.captureTimelineZoom =
|
|
value === 75 || value === 150 || value === 200 || value === 300 ? value : 100;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-timeline-sparkline-mode")
|
|
?.addEventListener("change", (e) => {
|
|
state.captureTimelineSparklineMode =
|
|
(e.currentTarget as HTMLSelectElement).value === "lane-relative"
|
|
? "lane-relative"
|
|
: "session-relative";
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLButtonElement>("#capture-timeline-clear-window")
|
|
?.addEventListener("click", () => {
|
|
state.captureTimelineWindowStartPct = null;
|
|
state.captureTimelineWindowEndPct = null;
|
|
state.captureTimelineBrushAnchorPct = null;
|
|
state.captureTimelineBrushCurrentPct = null;
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLInputElement>("#capture-timeline-focus-flow")
|
|
?.addEventListener("change", (e) => {
|
|
state.captureTimelineFocusSelectedFlow = (e.currentTarget as HTMLInputElement).checked;
|
|
if (!state.captureTimelineFocusSelectedFlow) {
|
|
state.captureTimelineFocusedLaneMode = "all";
|
|
state.captureTimelineFocusedLaneThreshold = "any";
|
|
}
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-timeline-focused-lane-mode")
|
|
?.addEventListener("change", (e) => {
|
|
const value = (e.currentTarget as HTMLSelectElement).value;
|
|
state.captureTimelineFocusedLaneMode =
|
|
value === "only-matching" || value === "collapse-background" ? value : "all";
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-timeline-focused-lane-threshold")
|
|
?.addEventListener("change", (e) => {
|
|
const value = (e.currentTarget as HTMLSelectElement).value;
|
|
state.captureTimelineFocusedLaneThreshold =
|
|
value === "events-2" || value === "percent-10" || value === "percent-25" ? value : "any";
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLSelectElement>("#capture-detail-placement")
|
|
?.addEventListener("change", (e) => {
|
|
state.captureDetailPlacement =
|
|
(e.currentTarget as HTMLSelectElement).value === "bottom" ? "bottom" : "right";
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLElement>("[data-capture-detail-splitter]")
|
|
?.addEventListener("mousedown", (event) => {
|
|
if (event.button !== 0 || state.captureDetailPlacement !== "right") {
|
|
return;
|
|
}
|
|
const splitRoot = root.querySelector<HTMLElement>("[data-capture-detail-split-root]");
|
|
if (!splitRoot) {
|
|
return;
|
|
}
|
|
const rect = splitRoot.getBoundingClientRect();
|
|
state.captureDetailSplitDragging = true;
|
|
render();
|
|
const handleMove = (moveEvent: MouseEvent) => {
|
|
const localX = moveEvent.clientX - rect.left;
|
|
const nextPct = ((rect.width - localX) / rect.width) * 100;
|
|
state.captureDetailSplitPct = Math.max(22, Math.min(55, Number(nextPct.toFixed(2))));
|
|
render();
|
|
};
|
|
const handleUp = () => {
|
|
state.captureDetailSplitDragging = false;
|
|
window.removeEventListener("mousemove", handleMove);
|
|
window.removeEventListener("mouseup", handleUp);
|
|
render();
|
|
};
|
|
window.addEventListener("mousemove", handleMove);
|
|
window.addEventListener("mouseup", handleUp);
|
|
event.preventDefault();
|
|
});
|
|
root
|
|
.querySelector<HTMLElement>("[data-capture-detail-splitter]")
|
|
?.addEventListener("dblclick", () => {
|
|
state.captureDetailSplitPct = 34;
|
|
state.captureDetailSplitDragging = false;
|
|
render();
|
|
});
|
|
root.querySelectorAll<HTMLInputElement>('input[name="capture-detail-view"]').forEach((node) => {
|
|
node.addEventListener("change", () => {
|
|
if (!node.checked) {
|
|
return;
|
|
}
|
|
const value = node.value;
|
|
state.captureDetailView =
|
|
value === "flow" || value === "payload" || value === "headers" ? value : "overview";
|
|
state.capturePreferredDetailView = state.captureDetailView;
|
|
render();
|
|
});
|
|
});
|
|
root.querySelectorAll<HTMLInputElement>('input[name="capture-flow-layout"]').forEach((node) => {
|
|
node.addEventListener("change", () => {
|
|
if (!node.checked) {
|
|
return;
|
|
}
|
|
state.captureFlowDetailLayout = node.value === "pair-first" ? "pair-first" : "nav-first";
|
|
render();
|
|
});
|
|
});
|
|
root
|
|
.querySelectorAll<HTMLInputElement>('input[name="capture-payload-layout"]')
|
|
.forEach((node) => {
|
|
node.addEventListener("change", () => {
|
|
if (!node.checked) {
|
|
return;
|
|
}
|
|
state.capturePayloadDetailLayout = node.value === "raw" ? "raw" : "formatted";
|
|
render();
|
|
});
|
|
});
|
|
root
|
|
.querySelectorAll<HTMLInputElement>('input[name="capture-payload-extent"]')
|
|
.forEach((node) => {
|
|
node.addEventListener("change", () => {
|
|
if (!node.checked) {
|
|
return;
|
|
}
|
|
state.capturePayloadExtent = node.value === "full" ? "full" : "preview";
|
|
render();
|
|
});
|
|
});
|
|
root
|
|
.querySelectorAll<HTMLInputElement>('input[name="capture-payload-event-sort"]')
|
|
.forEach((node) => {
|
|
node.addEventListener("change", () => {
|
|
if (!node.checked) {
|
|
return;
|
|
}
|
|
state.capturePayloadEventSort =
|
|
node.value === "name" || node.value === "size" ? node.value : "stream";
|
|
render();
|
|
});
|
|
});
|
|
root
|
|
.querySelector<HTMLInputElement>("#capture-payload-event-filter")
|
|
?.addEventListener("input", (e) => {
|
|
state.capturePayloadEventFilter = (e.currentTarget as HTMLInputElement).value ?? "";
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLInputElement>("#capture-search-filter")
|
|
?.addEventListener("input", (e) => {
|
|
state.captureSearchText = (e.currentTarget as HTMLInputElement).value ?? "";
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLInputElement>("#capture-errors-only")
|
|
?.addEventListener("change", (e) => {
|
|
state.captureErrorsOnly = (e.currentTarget as HTMLInputElement).checked;
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLButtonElement>("#capture-summary-toggle")
|
|
?.addEventListener("click", () => {
|
|
state.captureSummaryExpanded = !state.captureSummaryExpanded;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLButtonElement>("#capture-controls-toggle")
|
|
?.addEventListener("click", () => {
|
|
state.captureControlsExpanded = !state.captureControlsExpanded;
|
|
render();
|
|
});
|
|
root
|
|
.querySelector<HTMLButtonElement>("#capture-clear-filters")
|
|
?.addEventListener("click", () => {
|
|
state.captureKindFilter = [];
|
|
state.captureProviderFilter = [];
|
|
state.captureHostFilter = [];
|
|
state.captureSearchText = "";
|
|
state.captureHeaderMode = "key";
|
|
state.captureViewMode = "list";
|
|
state.captureGroupMode = "none";
|
|
state.captureTimelineLaneMode = "domain";
|
|
state.captureTimelineLaneSort = "most-events";
|
|
state.captureTimelinePreviousLaneSort = null;
|
|
state.captureTimelineLaneSearch = "";
|
|
state.captureTimelineZoom = 100;
|
|
state.captureTimelineSparklineMode = "session-relative";
|
|
state.captureTimelineWindowStartPct = null;
|
|
state.captureTimelineWindowEndPct = null;
|
|
state.captureTimelineBrushAnchorPct = null;
|
|
state.captureTimelineBrushCurrentPct = null;
|
|
state.captureTimelineFocusSelectedFlow = false;
|
|
state.captureTimelineFocusedLaneMode = "all";
|
|
state.captureTimelineFocusedLaneThreshold = "any";
|
|
state.captureErrorsOnly = false;
|
|
state.captureCollapsedLaneIds = [];
|
|
state.capturePinnedLaneIds = [];
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root.querySelectorAll<HTMLElement>("[data-capture-lane-toggle]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
const laneId = node.dataset.captureLaneToggle;
|
|
if (!laneId) {
|
|
return;
|
|
}
|
|
const collapsed = new Set(state.captureCollapsedLaneIds);
|
|
if (collapsed.has(laneId)) {
|
|
collapsed.delete(laneId);
|
|
} else {
|
|
collapsed.add(laneId);
|
|
}
|
|
state.captureCollapsedLaneIds = [...collapsed];
|
|
render();
|
|
});
|
|
});
|
|
root.querySelectorAll<HTMLElement>("[data-capture-lane-pin]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
const laneId = node.dataset.captureLanePin;
|
|
if (!laneId) {
|
|
return;
|
|
}
|
|
const pinned = new Set(state.capturePinnedLaneIds);
|
|
if (pinned.has(laneId)) {
|
|
pinned.delete(laneId);
|
|
} else {
|
|
pinned.add(laneId);
|
|
}
|
|
state.capturePinnedLaneIds = [...pinned];
|
|
render();
|
|
});
|
|
});
|
|
root.querySelectorAll<HTMLElement>("[data-capture-event]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
state.selectedCaptureEventKey = node.dataset.captureEvent ?? null;
|
|
render();
|
|
});
|
|
});
|
|
root.querySelectorAll<HTMLButtonElement>("[data-copy-text]").forEach((node) => {
|
|
node.addEventListener("click", async () => {
|
|
const text = node.dataset.copyText ?? "";
|
|
if (!text) {
|
|
return;
|
|
}
|
|
await navigator.clipboard.writeText(text).catch(() => undefined);
|
|
});
|
|
});
|
|
root.querySelectorAll<HTMLElement>("[data-capture-sparkline-window]").forEach((node) => {
|
|
const readWindow = () => {
|
|
const start = Number(node.dataset.captureWindowStart ?? "NaN");
|
|
const end = Number(node.dataset.captureWindowEnd ?? "NaN");
|
|
return Number.isFinite(start) && Number.isFinite(end) ? { start, end } : null;
|
|
};
|
|
node.addEventListener("mousedown", (event) => {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
const windowRange = readWindow();
|
|
if (!windowRange) {
|
|
return;
|
|
}
|
|
sparklineSweepActive = true;
|
|
sparklineSweepAnchorStartPct = windowRange.start;
|
|
sparklineSweepAnchorEndPct = windowRange.end;
|
|
sparklineSweepCurrentStartPct = windowRange.start;
|
|
sparklineSweepCurrentEndPct = windowRange.end;
|
|
state.captureTimelineBrushAnchorPct = windowRange.start;
|
|
state.captureTimelineBrushCurrentPct = windowRange.end;
|
|
render();
|
|
});
|
|
node.addEventListener("mouseenter", () => {
|
|
if (!sparklineSweepActive) {
|
|
return;
|
|
}
|
|
const windowRange = readWindow();
|
|
if (!windowRange) {
|
|
return;
|
|
}
|
|
sparklineSweepCurrentStartPct = windowRange.start;
|
|
sparklineSweepCurrentEndPct = windowRange.end;
|
|
const previewStart = Math.min(
|
|
sparklineSweepAnchorStartPct ?? windowRange.start,
|
|
windowRange.start,
|
|
);
|
|
const previewEnd = Math.max(sparklineSweepAnchorEndPct ?? windowRange.end, windowRange.end);
|
|
state.captureTimelineBrushAnchorPct = previewStart;
|
|
state.captureTimelineBrushCurrentPct = previewEnd;
|
|
render();
|
|
});
|
|
});
|
|
const timelineViewports = [...root.querySelectorAll<HTMLElement>(".capture-timeline-viewport")];
|
|
timelineViewports.forEach((node) => {
|
|
node.addEventListener("scroll", () => {
|
|
if (syncingCaptureTimelineScroll) {
|
|
return;
|
|
}
|
|
syncingCaptureTimelineScroll = true;
|
|
const nextLeft = node.scrollLeft;
|
|
for (const other of timelineViewports) {
|
|
if (other !== node && other.scrollLeft !== nextLeft) {
|
|
other.scrollLeft = nextLeft;
|
|
}
|
|
}
|
|
syncingCaptureTimelineScroll = false;
|
|
});
|
|
});
|
|
root.querySelectorAll<HTMLElement>("[data-capture-timeline-brush-surface]").forEach((node) => {
|
|
node.addEventListener("mousedown", (event) => {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
const viewport = node;
|
|
const trackWidth = Number(viewport.dataset.captureTimelineTrackWidth ?? "0");
|
|
if (!Number.isFinite(trackWidth) || trackWidth <= 0) {
|
|
return;
|
|
}
|
|
const percentFromEvent = (clientX: number) => {
|
|
const rect = viewport.getBoundingClientRect();
|
|
const localX = clientX - rect.left + viewport.scrollLeft;
|
|
return Math.min(100, Math.max(0, (localX / trackWidth) * 100));
|
|
};
|
|
const anchorPct = percentFromEvent(event.clientX);
|
|
state.captureTimelineBrushAnchorPct = anchorPct;
|
|
state.captureTimelineBrushCurrentPct = anchorPct;
|
|
render();
|
|
const handleMove = (moveEvent: MouseEvent) => {
|
|
state.captureTimelineBrushCurrentPct = percentFromEvent(moveEvent.clientX);
|
|
render();
|
|
};
|
|
const handleUp = () => {
|
|
const anchor = state.captureTimelineBrushAnchorPct;
|
|
const current = state.captureTimelineBrushCurrentPct;
|
|
if (anchor != null && current != null) {
|
|
const start = Math.min(anchor, current);
|
|
const end = Math.max(anchor, current);
|
|
if (end - start >= 1) {
|
|
state.captureTimelineWindowStartPct = start;
|
|
state.captureTimelineWindowEndPct = end;
|
|
state.selectedCaptureEventKey = null;
|
|
}
|
|
}
|
|
state.captureTimelineBrushAnchorPct = null;
|
|
state.captureTimelineBrushCurrentPct = null;
|
|
window.removeEventListener("mousemove", handleMove);
|
|
window.removeEventListener("mouseup", handleUp);
|
|
render();
|
|
};
|
|
window.addEventListener("mousemove", handleMove);
|
|
window.addEventListener("mouseup", handleUp);
|
|
});
|
|
});
|
|
if (!captureGlobalListenersBound) {
|
|
captureGlobalListenersBound = true;
|
|
window.addEventListener("mouseup", (event) => {
|
|
if (!sparklineSweepActive) {
|
|
return;
|
|
}
|
|
const anchorStart = sparklineSweepAnchorStartPct;
|
|
const anchorEnd = sparklineSweepAnchorEndPct;
|
|
const currentStart = sparklineSweepCurrentStartPct;
|
|
const currentEnd = sparklineSweepCurrentEndPct;
|
|
sparklineSweepActive = false;
|
|
sparklineSweepAnchorStartPct = null;
|
|
sparklineSweepAnchorEndPct = null;
|
|
sparklineSweepCurrentStartPct = null;
|
|
sparklineSweepCurrentEndPct = null;
|
|
if (
|
|
anchorStart == null ||
|
|
anchorEnd == null ||
|
|
currentStart == null ||
|
|
currentEnd == null
|
|
) {
|
|
state.captureTimelineBrushAnchorPct = null;
|
|
state.captureTimelineBrushCurrentPct = null;
|
|
render();
|
|
return;
|
|
}
|
|
const start = Math.min(anchorStart, currentStart);
|
|
const end = Math.max(anchorEnd, currentEnd);
|
|
const width = Math.max(0.01, end - start);
|
|
const expand = event.shiftKey ? width : 0;
|
|
state.captureTimelineWindowStartPct = Math.max(0, Math.min(100, start - expand));
|
|
state.captureTimelineWindowEndPct = Math.max(0, Math.min(100, end + expand));
|
|
state.captureTimelineBrushAnchorPct = null;
|
|
state.captureTimelineBrushCurrentPct = null;
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
});
|
|
root.addEventListener("keydown", (event) => {
|
|
if (state.activeTab !== "capture") {
|
|
return;
|
|
}
|
|
if (isEditableElement(event.target)) {
|
|
return;
|
|
}
|
|
if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4") {
|
|
const radios = [
|
|
...root.querySelectorAll<HTMLInputElement>('input[name="capture-detail-view"]'),
|
|
].filter((node) => !node.disabled);
|
|
const index = Number(event.key) - 1;
|
|
const target = radios[index];
|
|
if (target) {
|
|
event.preventDefault();
|
|
target.checked = true;
|
|
state.captureDetailView =
|
|
target.value === "flow" || target.value === "payload" || target.value === "headers"
|
|
? target.value
|
|
: "overview";
|
|
state.capturePreferredDetailView = state.captureDetailView;
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
if (state.captureViewMode !== "timeline") {
|
|
return;
|
|
}
|
|
if (
|
|
event.key !== "ArrowLeft" &&
|
|
event.key !== "ArrowRight" &&
|
|
event.key !== "Home" &&
|
|
event.key !== "End" &&
|
|
event.key !== "Escape"
|
|
) {
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
if (
|
|
state.captureTimelineWindowStartPct != null ||
|
|
state.captureTimelineBrushAnchorPct != null
|
|
) {
|
|
event.preventDefault();
|
|
state.captureTimelineWindowStartPct = null;
|
|
state.captureTimelineWindowEndPct = null;
|
|
state.captureTimelineBrushAnchorPct = null;
|
|
state.captureTimelineBrushCurrentPct = null;
|
|
state.selectedCaptureEventKey = null;
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
const markers = [
|
|
...root.querySelectorAll<HTMLElement>(".capture-timeline [data-capture-event]"),
|
|
];
|
|
if (markers.length === 0) {
|
|
return;
|
|
}
|
|
const currentIndex = markers.findIndex(
|
|
(node) => (node.dataset.captureEvent ?? null) === state.selectedCaptureEventKey,
|
|
);
|
|
let nextIndex = Math.max(currentIndex, 0);
|
|
if (event.key === "Home") {
|
|
nextIndex = 0;
|
|
} else if (event.key === "End") {
|
|
nextIndex = markers.length - 1;
|
|
} else if (event.key === "ArrowLeft") {
|
|
nextIndex = currentIndex <= 0 ? 0 : currentIndex - 1;
|
|
} else if (event.key === "ArrowRight") {
|
|
nextIndex = currentIndex < 0 ? 0 : Math.min(markers.length - 1, currentIndex + 1);
|
|
}
|
|
const next = markers[nextIndex];
|
|
if (!next) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
state.selectedCaptureEventKey = next.dataset.captureEvent ?? null;
|
|
render();
|
|
});
|
|
}
|
|
|
|
/* Composer form */
|
|
root.querySelector<HTMLSelectElement>("#conversation-kind")?.addEventListener("change", (e) => {
|
|
state.composer.conversationKind =
|
|
(e.currentTarget as HTMLSelectElement).value === "channel" ? "channel" : "direct";
|
|
});
|
|
root.querySelector<HTMLInputElement>("#conversation-id")?.addEventListener("input", (e) => {
|
|
state.composer.conversationId = (e.currentTarget as HTMLInputElement).value;
|
|
});
|
|
root.querySelector<HTMLInputElement>("#sender-id")?.addEventListener("input", (e) => {
|
|
state.composer.senderId = (e.currentTarget as HTMLInputElement).value;
|
|
});
|
|
root.querySelector<HTMLInputElement>("#sender-name")?.addEventListener("input", (e) => {
|
|
state.composer.senderName = (e.currentTarget as HTMLInputElement).value;
|
|
});
|
|
|
|
/* Composer textarea: capture input + Enter-to-send */
|
|
const textarea = root.querySelector<HTMLTextAreaElement>("#composer-text");
|
|
if (textarea) {
|
|
textarea.addEventListener("input", (e) => {
|
|
state.composer.text = (e.currentTarget as HTMLTextAreaElement).value;
|
|
/* Auto-grow */
|
|
textarea.style.height = "auto";
|
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
|
|
});
|
|
textarea.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void sendInbound();
|
|
}
|
|
});
|
|
}
|
|
|
|
/* Chat scroll tracking */
|
|
trackChatScroll();
|
|
}
|
|
|
|
/* ---------- Render ---------- */
|
|
|
|
function render() {
|
|
/* Preserve focused element id so we can restore focus after re-render */
|
|
const focusedId = (document.activeElement as HTMLElement)?.id || null;
|
|
const composerText = state.composer.text;
|
|
|
|
root.innerHTML = renderQaLabUi(state);
|
|
bindEvents();
|
|
|
|
/* Restore composer text (since we re-rendered) */
|
|
const textEl = root.querySelector<HTMLTextAreaElement>("#composer-text");
|
|
if (textEl && composerText) {
|
|
textEl.value = composerText;
|
|
textEl.style.height = "auto";
|
|
textEl.style.height = `${Math.min(textEl.scrollHeight, 120)}px`;
|
|
}
|
|
|
|
/* Restore focus */
|
|
if (focusedId) {
|
|
const el = root.querySelector<HTMLElement>(`#${CSS.escape(focusedId)}`);
|
|
if (el && "focus" in el) {
|
|
el.focus();
|
|
}
|
|
}
|
|
|
|
if (
|
|
state.activeTab === "capture" &&
|
|
state.captureViewMode === "timeline" &&
|
|
state.selectedCaptureEventKey
|
|
) {
|
|
const selectedTimelineMarker = root.querySelector<HTMLElement>(
|
|
`.capture-timeline [data-capture-event="${CSS.escape(state.selectedCaptureEventKey)}"]`,
|
|
);
|
|
if (selectedTimelineMarker) {
|
|
selectedTimelineMarker.scrollIntoView({ block: "nearest", inline: "center" });
|
|
}
|
|
}
|
|
|
|
/* Auto-scroll chat */
|
|
requestAnimationFrame(() => scrollChatToBottom());
|
|
}
|
|
|
|
/* ---------- Bootstrap ---------- */
|
|
|
|
render();
|
|
await refresh();
|
|
void pollUiVersion();
|
|
setInterval(() => void refresh(), 1_000);
|
|
setInterval(() => void pollUiVersion(), 1_000);
|
|
}
|