feat(qa-lab): add Clawfather/Claw avatars and live-watch mode for scenario runs

This commit is contained in:
Peter Steinberger
2026-04-07 09:24:26 +01:00
parent 282188a326
commit 1baf5533aa
3 changed files with 64 additions and 37 deletions

View File

@@ -90,6 +90,7 @@ export async function createQaLabApp(root: HTMLDivElement) {
let lastFingerprint = "";
let renderDeferred = false;
let previousRunnerStatus: string | null = null;
function stateFingerprint(): string {
const msgs = state.snapshot?.messages;
@@ -161,6 +162,14 @@ export async function createQaLabApp(root: HTMLDivElement) {
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) {

View File

@@ -782,6 +782,36 @@ select {
background: var(--bg-inset);
}
.live-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 11px;
font-weight: 700;
color: var(--danger);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
animation: live-pulse 1.5s ease-in-out infinite;
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.chat-thread-chips {
display: flex;
align-items: center;
@@ -848,15 +878,14 @@ select {
}
.msg-avatar {
width: 34px;
height: 34px;
border-radius: 8px;
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: #fff;
font-size: 20px;
line-height: 1;
flex-shrink: 0;
margin-top: 2px;
}
@@ -879,12 +908,17 @@ select {
color: var(--text);
}
.msg-direction {
.msg-role {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
}
.msg-direction {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
padding: 1px 5px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}

View File

@@ -198,17 +198,6 @@ function esc(text: string) {
.replaceAll('"', "&quot;");
}
const AVATAR_COLORS = [
"#7c6cff",
"#f59e0b",
"#34d399",
"#f87171",
"#60a5fa",
"#a78bfa",
"#fb923c",
"#e879f9",
];
const MOCK_MODELS: RunnerModelOption[] = [
{
key: "mock-openai/gpt-5.4",
@@ -226,18 +215,6 @@ const MOCK_MODELS: RunnerModelOption[] = [
},
];
function avatarColor(name: string): string {
let h = 0;
for (const ch of name) {
h = (h * 31 + ch.charCodeAt(0)) | 0;
}
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
}
function avatarInitial(name: string): string {
return (name[0] ?? "?").toUpperCase();
}
export function deriveSelectedConversation(state: UiState): string | null {
return state.selectedConversationId ?? state.snapshot?.conversations[0]?.id ?? null;
}
@@ -579,6 +556,7 @@ function renderChatView(state: UiState): string {
<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 -->
@@ -612,12 +590,17 @@ function renderChatView(state: UiState): string {
</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 color = avatarColor(name);
const initial = avatarInitial(name);
const avatar = messageAvatar(m);
const dirClass = m.direction === "inbound" ? "msg-direction-inbound" : "msg-direction-outbound";
const dirLabel = m.direction === "inbound" ? "user" : "bot";
const metaTags: string[] = [];
if (m.threadId) {
@@ -637,11 +620,12 @@ function renderMessage(m: Message): string {
return `
<div class="msg msg-${m.direction}">
<div class="msg-avatar" style="background:${color}">${initial}</div>
<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-direction ${dirClass}">${dirLabel}</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>