fix(qa-lab): add Slack-style chat sidebar and fix light mode theming

This commit is contained in:
Peter Steinberger
2026-04-06 19:21:10 +01:00
parent e43a1f235e
commit 0cebe9d593
2 changed files with 243 additions and 62 deletions

View File

@@ -32,8 +32,12 @@
--warning-text: #fbbf24;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--msg-inbound-accent: #f59e0b;
--msg-inbound-badge: rgba(245, 158, 11, 0.15);
--msg-outbound-accent: #7c6cff;
--msg-outbound-bg: rgba(124, 108, 255, 0.03);
--chat-sidebar-bg: #101016;
--chat-sidebar-hover: rgba(255, 255, 255, 0.06);
--chat-sidebar-active: rgba(124, 108, 255, 0.14);
--scrollbar-thumb: rgba(255, 255, 255, 0.1);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.18);
}
@@ -66,8 +70,12 @@
--warning-text: #b45309;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
--msg-inbound-accent: #d97706;
--msg-inbound-badge: rgba(217, 119, 6, 0.1);
--msg-outbound-accent: #5b4cdb;
--msg-outbound-bg: rgba(91, 76, 219, 0.02);
--chat-sidebar-bg: #f9f9fb;
--chat-sidebar-hover: rgba(0, 0, 0, 0.04);
--chat-sidebar-active: rgba(91, 76, 219, 0.1);
--scrollbar-thumb: rgba(0, 0, 0, 0.12);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
}
@@ -647,26 +655,144 @@ select {
/* --- Chat view --- */
.chat-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat-context-bar {
/* Chat sidebar (channels / DMs) */
.chat-sidebar {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
background: var(--chat-sidebar-bg);
overflow: hidden;
}
.chat-sidebar-section {
padding: 10px 10px 6px;
}
.chat-sidebar-heading {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
padding: 0 6px 6px;
}
.chat-sidebar-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.chat-sidebar-scroll {
flex: 1;
overflow-y: auto;
}
.chat-sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
width: 100%;
padding: 6px 10px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 80ms ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-sidebar-item:hover {
background: var(--chat-sidebar-hover);
color: var(--text);
}
.chat-sidebar-item.active {
background: var(--chat-sidebar-active);
color: var(--text);
font-weight: 600;
}
.chat-sidebar-icon {
font-size: 14px;
flex-shrink: 0;
width: 18px;
text-align: center;
color: var(--text-tertiary);
}
.chat-sidebar-item.active .chat-sidebar-icon {
color: var(--accent);
}
.chat-sidebar-label {
overflow: hidden;
text-overflow: ellipsis;
}
.chat-sidebar-badge {
margin-left: auto;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
}
/* Chat main area (messages + composer) */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.chat-channel-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
flex-shrink: 0;
flex-wrap: wrap;
}
.conv-chip {
padding: 4px 12px;
.chat-channel-name {
font-size: 14px;
font-weight: 700;
color: var(--text);
}
.chat-channel-kind {
font-size: 11px;
color: var(--text-tertiary);
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-inset);
}
.chat-thread-chips {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.thread-chip {
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-size: 11px;
font-weight: 500;
border: 1px solid var(--border);
background: transparent;
@@ -675,23 +801,17 @@ select {
transition: all 100ms ease;
}
.conv-chip:hover {
.thread-chip:hover {
border-color: var(--border-strong);
color: var(--text);
}
.conv-chip.active {
.thread-chip.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.conv-chip-divider {
width: 1px;
height: 18px;
background: var(--border);
}
.chat-messages {
flex: 1;
overflow-y: auto;
@@ -769,7 +889,7 @@ select {
}
.msg-direction-inbound {
background: rgba(245, 158, 11, 0.12);
background: var(--msg-inbound-badge);
color: var(--msg-inbound-accent);
}
@@ -824,7 +944,7 @@ select {
.chat-composer {
border-top: 1px solid var(--border);
padding: 12px 20px;
background: var(--bg-sidebar);
background: var(--bg-surface);
flex-shrink: 0;
}
@@ -844,7 +964,7 @@ select {
padding: 3px 8px;
font-size: 12px;
border-radius: 6px;
background: var(--bg-surface);
background: var(--bg-elevated);
}
.composer-context select {
@@ -867,7 +987,7 @@ select {
max-height: 120px;
padding: 8px 12px;
border-radius: 10px;
background: var(--bg-surface);
background: var(--bg-elevated);
border: 1px solid var(--border);
line-height: 1.45;
}
@@ -1277,4 +1397,8 @@ select {
border-right: none;
border-bottom: 1px solid var(--border);
}
.chat-sidebar {
width: 180px;
}
}

View File

@@ -489,11 +489,14 @@ function renderTabBar(state: UiState): string {
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,
@@ -502,50 +505,104 @@ function renderChatView(state: UiState): string {
return `
<div class="chat-view">
<!-- Conversation / thread chips -->
<div class="chat-context-bar">
${conversations
.map(
(c) =>
`<button class="conv-chip${c.id === selectedConv ? " active" : ""}" data-conversation-id="${esc(c.id)}">${esc(c.title || c.id)} <span class="text-dimmed text-sm">${c.kind}</span></button>`,
)
.join("")}
${conversations.length > 0 && threads.length > 0 ? '<span class="conv-chip-divider"></span>' : ""}
<button class="conv-chip${!selectedThread ? " active" : ""}" data-thread-select="root">Main</button>
${threads
.map(
(t) =>
`<button class="conv-chip${t.id === selectedThread ? " active" : ""}" data-thread-select="${esc(t.id)}" data-thread-conv="${esc(t.conversationId)}">${esc(t.title)}</button>`,
)
.join("")}
${conversations.length === 0 ? '<span class="text-dimmed text-sm">No conversations yet</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)}" />
<!-- 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>
<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>
</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>` : ""}
</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>`;